mirror of
https://github.com/dqzboy/Docker-Proxy.git
synced 2026-01-12 16:25:42 +08:00
fix: BUGFIX: Fixes the issue with the Docker Hub login process and improves error handling in the UI.
This commit is contained in:
@@ -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
|
||||
|
||||
186
hubcmdui/app.js
Normal file
186
hubcmdui/app.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
72
hubcmdui/cleanup.js
Normal file
72
hubcmdui/cleanup.js
Normal file
@@ -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
|
||||
};
|
||||
1148
hubcmdui/compatibility-layer.js
Normal file
1148
hubcmdui/compatibility-layer.js
Normal file
File diff suppressed because it is too large
Load Diff
54
hubcmdui/config.js
Normal file
54
hubcmdui/config.js
Normal file
@@ -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
|
||||
};
|
||||
@@ -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": "",
|
||||
|
||||
1
hubcmdui/config/menu.json
Normal file
1
hubcmdui/config/menu.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
8
hubcmdui/config/monitoring.json
Normal file
8
hubcmdui/config/monitoring.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"isEnabled": false,
|
||||
"notificationType": "wechat",
|
||||
"webhookUrl": "",
|
||||
"telegramToken": "",
|
||||
"telegramChatId": "",
|
||||
"monitorInterval": 60
|
||||
}
|
||||
29
hubcmdui/data/config.json
Normal file
29
hubcmdui/data/config.json
Normal file
@@ -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"
|
||||
}
|
||||
@@ -8,3 +8,13 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
ports:
|
||||
- 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个
|
||||
BIN
hubcmdui/documentation/.DS_Store
vendored
Normal file
BIN
hubcmdui/documentation/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
7
hubcmdui/documentation/1743542841590.json
Normal file
7
hubcmdui/documentation/1743542841590.json
Normal file
@@ -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"
|
||||
}
|
||||
7
hubcmdui/documentation/1743543376091.json
Normal file
7
hubcmdui/documentation/1743543376091.json
Normal file
@@ -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"
|
||||
}
|
||||
7
hubcmdui/documentation/1743543400369.json
Normal file
7
hubcmdui/documentation/1743543400369.json
Normal file
@@ -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"
|
||||
}
|
||||
83
hubcmdui/download-images.js
Normal file
83
hubcmdui/download-images.js
Normal file
@@ -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 };
|
||||
86
hubcmdui/init-dirs.js
Normal file
86
hubcmdui/init-dirs.js
Normal file
@@ -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 };
|
||||
@@ -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) {
|
||||
async function ensureLogDir() {
|
||||
if (!LOG_CONFIG.file.enabled) return;
|
||||
|
||||
try {
|
||||
await fs.access(config.logDirectory);
|
||||
await fsPromises.access(LOG_CONFIG.file.dir);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
await fs.mkdir(config.logDirectory, { recursive: true });
|
||||
console.log(`Created log directory: ${config.logDirectory}`);
|
||||
await fsPromises.mkdir(LOG_CONFIG.file.dir, { recursive: true });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
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',
|
||||
// 检查是否需要轮转日志
|
||||
async function checkRotation() {
|
||||
if (!LOG_CONFIG.file.enabled) return false;
|
||||
|
||||
// 前景色
|
||||
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}`;
|
||||
const currentLogFile = getCurrentLogFile();
|
||||
try {
|
||||
const stats = await fsPromises.stat(currentLogFile);
|
||||
if (stats.size >= LOG_CONFIG.file.maxSize) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// 文件不存在,不需要轮转
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.error('检查日志文件大小失败:', err);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 轮转日志文件
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理旧日志文件
|
||||
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]+)?$`);
|
||||
|
||||
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); // 按修改时间降序排序
|
||||
|
||||
// 保留最新的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);
|
||||
}
|
||||
}
|
||||
|
||||
// 写入日志文件
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日志消息
|
||||
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 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 })}`;
|
||||
detailsStr = ` ${details}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 为控制台格式化日志
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
formatted: consoleOutput,
|
||||
json: JSON.stringify(logObject)
|
||||
console: LOG_CONFIG.console.colorize
|
||||
? `${timestamp} ${colors[level.color](prefix)} ${message}${detailsStr}`
|
||||
: `${timestamp} ${prefix} ${message}${detailsStr}`,
|
||||
file: `${standardMessage}${detailsStr}`
|
||||
};
|
||||
}
|
||||
|
||||
// 日志函数
|
||||
async function log(level, message, meta = {}) {
|
||||
if (LOG_LEVELS[level] < LOG_LEVELS[config.level]) {
|
||||
// 检查当前日志级别是否应该记录指定级别的日志
|
||||
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 { formatted, json } = createLogEntry(level, message, meta);
|
||||
const formattedMessage = formatLogMessage(LOG_LEVELS[level], message, details);
|
||||
|
||||
// 控制台输出
|
||||
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}`);
|
||||
if (LOG_CONFIG.console.enabled) {
|
||||
console.log(formattedMessage.console);
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
if (LOG_CONFIG.file.enabled) {
|
||||
writeToLogFile(formattedMessage.file);
|
||||
}
|
||||
}
|
||||
|
||||
// 日志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),
|
||||
|
||||
// 配置方法
|
||||
configure: (options) => {
|
||||
Object.assign(config, options);
|
||||
},
|
||||
|
||||
// HTTP请求日志方法 - 简化输出格式
|
||||
request: (req, res, duration) => {
|
||||
const status = res.statusCode;
|
||||
// 请求日志函数
|
||||
function request(req, res, duration) {
|
||||
const method = req.method;
|
||||
const url = req.originalUrl || req.url;
|
||||
const userAgent = req.headers['user-agent'] || '-';
|
||||
const ip = req.ip || req.connection.remoteAddress || '-';
|
||||
const status = res.statusCode;
|
||||
const ip = req.ip ? req.ip.replace(/::ffff:/, '') : 'unknown';
|
||||
|
||||
let level = 'info';
|
||||
if (status >= 500) level = 'error';
|
||||
else if (status >= 400) level = 'warn';
|
||||
// 根据状态码确定日志级别
|
||||
let level = 'INFO';
|
||||
if (status >= 400 && status < 500) level = 'WARN';
|
||||
if (status >= 500) level = 'ERROR';
|
||||
|
||||
// 为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 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;
|
||||
}
|
||||
|
||||
// 简化的请求日志格式
|
||||
const message = `${method} ${url} ${statusIndicator} ${duration}ms`;
|
||||
|
||||
// 传递ip和userAgent作为元数据,但以简洁方式显示
|
||||
log(level, message, { ip, userAgent });
|
||||
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
|
||||
};
|
||||
|
||||
// 初始化
|
||||
ensureLogDirectory().catch(err => {
|
||||
console.error(`${COLORS.red}Failed to initialize logger: ${err.message}${COLORS.reset}`);
|
||||
});
|
||||
|
||||
module.exports = logger;
|
||||
90
hubcmdui/middleware/auth.js
Normal file
90
hubcmdui/middleware/auth.js
Normal file
@@ -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
|
||||
};
|
||||
26
hubcmdui/middleware/client-error.js
Normal file
26
hubcmdui/middleware/client-error.js
Normal file
@@ -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;
|
||||
13
hubcmdui/models/MenuItem.js
Normal file
13
hubcmdui/models/MenuItem.js
Normal file
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
155
hubcmdui/routes/auth.js
Normal file
155
hubcmdui/routes/auth.js
Normal file
@@ -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;
|
||||
224
hubcmdui/routes/config.js
Normal file
224
hubcmdui/routes/config.js
Normal file
@@ -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;
|
||||
146
hubcmdui/routes/docker.js
Normal file
146
hubcmdui/routes/docker.js
Normal file
@@ -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;
|
||||
65
hubcmdui/routes/dockerhub.js
Normal file
65
hubcmdui/routes/dockerhub.js
Normal file
@@ -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;
|
||||
537
hubcmdui/routes/documentation.js
Normal file
537
hubcmdui/routes/documentation.js
Normal file
@@ -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;
|
||||
48
hubcmdui/routes/health.js
Normal file
48
hubcmdui/routes/health.js
Normal file
@@ -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;
|
||||
78
hubcmdui/routes/index.js
Normal file
78
hubcmdui/routes/index.js
Normal file
@@ -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;
|
||||
92
hubcmdui/routes/login.js
Normal file
92
hubcmdui/routes/login.js
Normal file
@@ -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;
|
||||
193
hubcmdui/routes/monitoring.js
Normal file
193
hubcmdui/routes/monitoring.js
Normal file
@@ -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;
|
||||
55
hubcmdui/routes/routeLoader.js
Normal file
55
hubcmdui/routes/routeLoader.js
Normal file
@@ -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;
|
||||
590
hubcmdui/routes/system.js
Normal file
590
hubcmdui/routes/system.js
Normal file
@@ -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
|
||||
104
hubcmdui/routes/systemStatus.js
Normal file
104
hubcmdui/routes/systemStatus.js
Normal file
@@ -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;
|
||||
163
hubcmdui/scripts/diagnostics.js
Normal file
163
hubcmdui/scripts/diagnostics.js
Normal file
@@ -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 };
|
||||
25
hubcmdui/scripts/init-menu.js
Normal file
25
hubcmdui/scripts/init-menu.js
Normal file
@@ -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;
|
||||
315
hubcmdui/scripts/init-system.js
Normal file
315
hubcmdui/scripts/init-system.js
Normal file
@@ -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
|
||||
};
|
||||
333
hubcmdui/server-utils.js
Normal file
333
hubcmdui/server-utils.js
Normal file
@@ -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<string>} 命令输出结果
|
||||
*/
|
||||
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<object>} 系统信息对象
|
||||
*/
|
||||
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<object>} 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<object>} 磁盘信息
|
||||
*/
|
||||
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
|
||||
};
|
||||
1907
hubcmdui/server.js
1907
hubcmdui/server.js
File diff suppressed because it is too large
Load Diff
47
hubcmdui/services/configService.js
Normal file
47
hubcmdui/services/configService.js
Normal file
@@ -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
|
||||
};
|
||||
290
hubcmdui/services/dockerHubService.js
Normal file
290
hubcmdui/services/dockerHubService.js
Normal file
@@ -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
|
||||
};
|
||||
476
hubcmdui/services/dockerService.js
Normal file
476
hubcmdui/services/dockerService.js
Normal file
@@ -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
|
||||
};
|
||||
324
hubcmdui/services/documentationService.js
Normal file
324
hubcmdui/services/documentationService.js
Normal file
@@ -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
|
||||
};
|
||||
331
hubcmdui/services/monitoringService.js
Normal file
331
hubcmdui/services/monitoringService.js
Normal file
@@ -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
|
||||
};
|
||||
52
hubcmdui/services/networkService.js
Normal file
52
hubcmdui/services/networkService.js
Normal file
@@ -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<string>} 测试结果
|
||||
*/
|
||||
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
|
||||
};
|
||||
103
hubcmdui/services/notificationService.js
Normal file
103
hubcmdui/services/notificationService.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 通知服务
|
||||
* 用于发送各种类型的通知
|
||||
*/
|
||||
const axios = require('axios');
|
||||
const logger = require('../logger');
|
||||
|
||||
/**
|
||||
* 发送通知
|
||||
* @param {Object} message - 消息对象,包含标题、内容等
|
||||
* @param {Object} config - 配置对象,包含通知类型和相关配置
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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
|
||||
};
|
||||
55
hubcmdui/services/systemService.js
Normal file
55
hubcmdui/services/systemService.js
Normal file
@@ -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
|
||||
};
|
||||
175
hubcmdui/services/userService.js
Normal file
175
hubcmdui/services/userService.js
Normal file
@@ -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
|
||||
};
|
||||
58
hubcmdui/start-diagnostic.js
Normal file
58
hubcmdui/start-diagnostic.js
Normal file
@@ -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();
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
hubcmdui/web/.DS_Store
vendored
Normal file
BIN
hubcmdui/web/.DS_Store
vendored
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
53
hubcmdui/web/compatibility-layer.js
Normal file
53
hubcmdui/web/compatibility-layer.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// ... existing code ...
|
||||
// 获取文档列表
|
||||
app.get('/api/documentation', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const docList = await getDocumentList();
|
||||
res.json(docList);
|
||||
} catch (error) {
|
||||
console.error('获取文档列表失败:', error);
|
||||
res.status(500).json({ error: '获取文档列表失败', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单个文档内容
|
||||
app.get('/api/documentation/:id', requireLogin, async (req, res) => {
|
||||
const docId = req.params.id;
|
||||
console.log(`获取文档内容请求,ID: ${docId}`);
|
||||
|
||||
try {
|
||||
// 获取文档列表
|
||||
const docList = await getDocumentList();
|
||||
|
||||
// 查找指定ID的文档
|
||||
const doc = docList.find(doc => doc.id === docId || doc._id === docId);
|
||||
|
||||
if (!doc) {
|
||||
return res.status(404).json({ error: '文档不存在', docId });
|
||||
}
|
||||
|
||||
// 如果文档未发布且用户不是管理员,则拒绝访问
|
||||
if (!doc.published && !isAdmin(req.user)) {
|
||||
return res.status(403).json({ error: '无权访问未发布的文档' });
|
||||
}
|
||||
|
||||
// 获取文档完整内容
|
||||
const docContent = await getDocumentContent(docId);
|
||||
|
||||
// 合并文档信息和内容
|
||||
const fullDoc = {
|
||||
...doc,
|
||||
content: docContent
|
||||
};
|
||||
|
||||
res.json(fullDoc);
|
||||
} catch (error) {
|
||||
console.error(`获取文档内容失败,ID: ${docId}`, error);
|
||||
res.status(500).json({
|
||||
error: '获取文档内容失败',
|
||||
details: error.message,
|
||||
docId
|
||||
});
|
||||
}
|
||||
});
|
||||
// ... existing code ...
|
||||
396
hubcmdui/web/css/admin.css
Normal file
396
hubcmdui/web/css/admin.css
Normal file
@@ -0,0 +1,396 @@
|
||||
/* 今天登录的高亮样式 */
|
||||
.today-login {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 用户信息显示优化 */
|
||||
.account-info-item {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.account-info-item .label {
|
||||
font-weight: 500;
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.account-info-item .value {
|
||||
color: #1f2937;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 加载中占位符样式 */
|
||||
.loading-placeholder {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
height: 14px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 菜单编辑弹窗样式 */
|
||||
.menu-edit-popup {
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.menu-edit-title {
|
||||
font-size: 1.5em;
|
||||
color: #1f2937;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #f3f4f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-edit-container {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.menu-edit-container .form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.menu-edit-container label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #4b5563;
|
||||
font-weight: 500;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.menu-edit-container .swal2-input,
|
||||
.menu-edit-container .swal2-select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.menu-edit-container .swal2-input:hover,
|
||||
.menu-edit-container .swal2-select:hover {
|
||||
border-color: #d1d5db;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.menu-edit-container .swal2-input:focus,
|
||||
.menu-edit-container .swal2-select:focus {
|
||||
border-color: #4CAF50;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.menu-edit-confirm {
|
||||
background-color: #4CAF50 !important;
|
||||
padding: 10px 24px !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
font-size: 14px !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.menu-edit-confirm:hover {
|
||||
background-color: #45a049 !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.menu-edit-cancel {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #4b5563 !important;
|
||||
padding: 10px 24px !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
font-size: 14px !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.menu-edit-cancel:hover {
|
||||
background-color: #e5e7eb !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 操作按钮样式优化 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background-color: #f3f4f6;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: #e5e7eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-btn.edit-btn {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.action-btn.edit-btn:hover {
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.action-btn.delete-btn {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-btn.delete-btn:hover {
|
||||
background-color: #fee2e2;
|
||||
}
|
||||
|
||||
.action-btn.log-btn {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.action-btn.log-btn:hover {
|
||||
background-color: #d1fae5;
|
||||
}
|
||||
|
||||
.action-btn.start-btn {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.action-btn.start-btn:hover {
|
||||
background-color: #d1fae5;
|
||||
}
|
||||
|
||||
.action-btn.stop-btn {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-btn.stop-btn:hover {
|
||||
background-color: #fee2e2;
|
||||
}
|
||||
|
||||
.action-btn.restart-btn {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.action-btn.restart-btn:hover {
|
||||
background-color: #fef3c7;
|
||||
}
|
||||
|
||||
/* Docker 状态指示器样式 */
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.status-indicator.running {
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.status-indicator.stopped {
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
color: #F44336;
|
||||
}
|
||||
|
||||
.status-indicator i {
|
||||
margin-right: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 文档操作按钮样式优化 */
|
||||
.view-btn {
|
||||
background-color: #f0f9ff !important;
|
||||
color: #0ea5e9 !important;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background-color: #e0f2fe !important;
|
||||
color: #0284c7 !important;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background-color: #f0fdf4 !important;
|
||||
color: #22c55e !important;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background-color: #dcfce7 !important;
|
||||
color: #16a34a !important;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background-color: #fef2f2 !important;
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background-color: #fee2e2 !important;
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
background-color: #f0fdfa !important;
|
||||
color: #14b8a6 !important;
|
||||
}
|
||||
|
||||
.publish-btn:hover {
|
||||
background-color: #ccfbf1 !important;
|
||||
color: #0d9488 !important;
|
||||
}
|
||||
|
||||
.unpublish-btn {
|
||||
background-color: #fffbeb !important;
|
||||
color: #f59e0b !important;
|
||||
}
|
||||
|
||||
.unpublish-btn:hover {
|
||||
background-color: #fef3c7 !important;
|
||||
color: #d97706 !important;
|
||||
}
|
||||
|
||||
/* 刷新按钮交互反馈 */
|
||||
.refresh-btn {
|
||||
background-color: #f9fafb;
|
||||
color: #4b5563;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.refresh-btn.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.refresh-btn.loading i {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Docker未运行友好提示 */
|
||||
.docker-offline-container {
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.docker-offline-icon {
|
||||
font-size: 40px;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.docker-offline-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.docker-offline-message {
|
||||
color: #6b7280;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.docker-offline-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.docker-offline-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.docker-offline-btn.primary {
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.docker-offline-btn.primary:hover {
|
||||
background-color: #4338ca;
|
||||
}
|
||||
|
||||
.docker-offline-btn.secondary {
|
||||
background-color: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.docker-offline-btn.secondary:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
662
hubcmdui/web/css/custom.css
Normal file
662
hubcmdui/web/css/custom.css
Normal file
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* 自定义样式表 - 增强UI交互和视觉效果
|
||||
*/
|
||||
|
||||
/* 仪表盘错误通知 */
|
||||
.dashboard-error-notice {
|
||||
background-color: #fee;
|
||||
border-left: 4px solid #f44336;
|
||||
padding: 12px 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
animation: slideIn 0.4s ease-out;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.dashboard-error-notice.fade-out {
|
||||
animation: fadeOut 0.5s forwards;
|
||||
}
|
||||
|
||||
.dashboard-error-notice i {
|
||||
color: #f44336;
|
||||
font-size: 20px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.dashboard-error-notice span {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dashboard-error-notice button {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.dashboard-error-notice button:hover {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
/* Docker状态指示器样式增强 */
|
||||
#dockerStatusIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.docker-help-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Docker错误显示样式 */
|
||||
.docker-error-container {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.docker-error {
|
||||
background-color: #fff6f6;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
.docker-error i {
|
||||
color: #f44336;
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.docker-error h3 {
|
||||
color: #d32f2f;
|
||||
margin-bottom: 15px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.docker-error p {
|
||||
color: #555;
|
||||
margin-bottom: 20px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.docker-error .retry-btn,
|
||||
.docker-error .help-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
margin: 5px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.docker-error .retry-btn {
|
||||
background-color: #3d7cf4;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.docker-error .help-btn {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.docker-error .retry-btn:hover {
|
||||
background-color: #2962ff;
|
||||
}
|
||||
|
||||
.docker-error .help-btn:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 容器状态徽章 */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background-color: rgba(46, 204, 64, 0.15);
|
||||
color: #2ecc40;
|
||||
border: 1px solid rgba(46, 204, 64, 0.3);
|
||||
}
|
||||
|
||||
.status-stopped,
|
||||
.status-exited {
|
||||
background-color: rgba(255, 65, 54, 0.15);
|
||||
color: #ff4136;
|
||||
border: 1px solid rgba(255, 65, 54, 0.3);
|
||||
}
|
||||
|
||||
.status-paused {
|
||||
background-color: rgba(255, 133, 27, 0.15);
|
||||
color: #ff851b;
|
||||
border: 1px solid rgba(255, 133, 27, 0.3);
|
||||
}
|
||||
|
||||
.status-created {
|
||||
background-color: rgba(0, 116, 217, 0.15);
|
||||
color: #0074d9;
|
||||
border: 1px solid rgba(0, 116, 217, 0.3);
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
background-color: rgba(170, 170, 170, 0.15);
|
||||
color: #aaaaaa;
|
||||
border: 1px solid rgba(170, 170, 170, 0.3);
|
||||
}
|
||||
|
||||
/* 操作按钮样式 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-btn {
|
||||
background-color: #3d7cf4;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.start-btn,
|
||||
.unpause-btn {
|
||||
background-color: #2ecc40;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
background-color: #ff4136;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.restart-btn {
|
||||
background-color: #ff851b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background-color: #85144b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-animation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(61, 124, 244, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top: 4px solid #3d7cf4;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.loading-spinner-small {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(61, 124, 244, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top: 2px solid #3d7cf4;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
0% { transform: translateY(-20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(-20px); }
|
||||
}
|
||||
|
||||
/* 无容器状态样式 */
|
||||
.no-containers {
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.no-containers i {
|
||||
font-size: 32px;
|
||||
color: #aaa;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.no-containers p {
|
||||
font-size: 18px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.no-containers small {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Docker故障排除指南样式 */
|
||||
.troubleshooting-guide {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.troubleshooting-guide h4 {
|
||||
margin: 15px 0 10px;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.troubleshooting-guide ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.troubleshooting-guide li {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.troubleshooting-guide strong {
|
||||
color: #3d7cf4;
|
||||
}
|
||||
|
||||
.troubleshooting-guide .solution {
|
||||
background-color: #f8f8f8;
|
||||
padding: 8px 12px;
|
||||
margin-top: 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.troubleshooting-guide code {
|
||||
background-color: #e8e8e8;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.check-command,
|
||||
.docker-logs {
|
||||
margin-top: 20px;
|
||||
background-color: #f3f7ff;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
border-left: 3px solid #3d7cf4;
|
||||
}
|
||||
|
||||
/* 菜单管理美化 */
|
||||
#menuTable {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
margin-bottom: 30px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#menuTable th {
|
||||
background-color: #3d7cf4;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
font-size: 15px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#menuTable tr:nth-child(even) {
|
||||
background-color: rgba(61, 124, 244, 0.05);
|
||||
}
|
||||
|
||||
#menuTable td {
|
||||
padding: 12px 15px;
|
||||
border-top: 1px solid #e9edf5;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.new-item-row td {
|
||||
background-color: #f2f7ff !important;
|
||||
border-bottom: 2px solid #3d7cf4 !important;
|
||||
}
|
||||
|
||||
#menuTable input[type="text"],
|
||||
#menuTable select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#menuTable input[type="text"]:focus,
|
||||
#menuTable select:focus {
|
||||
border-color: #3d7cf4;
|
||||
box-shadow: 0 0 0 3px rgba(61, 124, 244, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 文档管理增强 */
|
||||
#documentTable {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
margin: 20px 0 30px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#documentTable th {
|
||||
background-color: #3d7cf4;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
font-size: 15px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#documentTable td {
|
||||
padding: 12px 15px;
|
||||
border-top: 1px solid #e9edf5;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 文档编辑器增强 */
|
||||
#editorContainer {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
|
||||
margin-top: 30px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#documentTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding: 15px 20px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #eee;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#documentTitle:focus {
|
||||
outline: none;
|
||||
border-bottom-color: #3d7cf4;
|
||||
}
|
||||
|
||||
.editormd-fullscreen {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
.editor-preview-side {
|
||||
border-left: 1px solid #ddd !important;
|
||||
}
|
||||
|
||||
.editor-statusbar {
|
||||
border-top: 1px solid #ddd !important;
|
||||
padding: 8px 15px !important;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
padding: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
background-color: #f9f9f9;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 文本输入框和表单样式增强 */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #3d7cf4;
|
||||
box-shadow: 0 0 0 3px rgba(61, 124, 244, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* SweetAlert2自定义样式 */
|
||||
.swal2-popup {
|
||||
padding: 25px !important;
|
||||
border-radius: 10px !important;
|
||||
width: auto !important;
|
||||
min-width: 400px !important;
|
||||
}
|
||||
|
||||
.swal2-title {
|
||||
font-size: 24px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.swal2-content {
|
||||
font-size: 16px !important;
|
||||
color: #555 !important;
|
||||
}
|
||||
|
||||
.swal2-input, .swal2-textarea, .swal2-select {
|
||||
margin: 15px auto !important;
|
||||
}
|
||||
|
||||
.swal2-styled.swal2-confirm {
|
||||
background-color: #3d7cf4 !important;
|
||||
padding: 12px 25px !important;
|
||||
font-size: 16px !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
/* 文档预览样式 */
|
||||
.document-preview-container {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
.document-preview-popup {
|
||||
max-width: 1000px !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.document-preview-content {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.document-preview {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.document-preview h1 {
|
||||
color: #2c3e50;
|
||||
margin-top: 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.document-preview h2 {
|
||||
color: #3d7cf4;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.document-preview pre {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.document-preview code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.document-preview img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 15px auto;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.document-preview table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.document-preview table th,
|
||||
.document-preview table td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.document-preview table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.document-preview blockquote {
|
||||
border-left: 4px solid #3d7cf4;
|
||||
padding: 10px 15px;
|
||||
color: #555;
|
||||
background-color: #f9f9f9;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
/* 为文档添加打印样式 */
|
||||
@media print {
|
||||
.document-preview {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.document-preview * {
|
||||
color: black !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#documentTable th,
|
||||
#documentTable td,
|
||||
#menuTable th,
|
||||
#menuTable td {
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.swal2-popup {
|
||||
min-width: 300px !important;
|
||||
padding: 15px !important;
|
||||
}
|
||||
}
|
||||
1
hubcmdui/web/data/documentation/index.json
Normal file
1
hubcmdui/web/data/documentation/index.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
BIN
hubcmdui/web/images/login-bg.jpg
Normal file
BIN
hubcmdui/web/images/login-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -206,7 +206,135 @@
|
||||
}
|
||||
}
|
||||
|
||||
let proxyDomain = ''; // 默认代理加速地址
|
||||
// ========================================
|
||||
// === 文档加载相关函数 (移到此处) ===
|
||||
// ========================================
|
||||
let documentationLoaded = false;
|
||||
async function loadAndDisplayDocumentation() {
|
||||
// 防止重复加载
|
||||
if (documentationLoaded) {
|
||||
console.log('文档已加载,跳过重复加载');
|
||||
return;
|
||||
}
|
||||
|
||||
const docListContainer = document.getElementById('documentList');
|
||||
const docContentContainer = document.getElementById('documentationText');
|
||||
|
||||
if (!docListContainer || !docContentContainer) {
|
||||
console.warn('找不到文档列表或内容容器,可能不是文档页面');
|
||||
return; // 如果容器不存在,则不执行加载
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('开始加载文档列表和内容...');
|
||||
|
||||
// 显示加载状态
|
||||
docListContainer.innerHTML = '<div class="loading-container"><i class="fas fa-spinner fa-spin"></i> 正在加载文档列表...</div>';
|
||||
docContentContainer.innerHTML = '<div class="loading-container"><i class="fas fa-spinner fa-spin"></i> 请从左侧选择文档...</div>';
|
||||
|
||||
// 获取文档列表
|
||||
const response = await fetch('/api/documentation');
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取文档列表失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('获取到文档列表:', data);
|
||||
|
||||
// 保存到全局变量
|
||||
window.documentationData = data;
|
||||
documentationLoaded = true; // 标记为已加载
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
docListContainer.innerHTML = `
|
||||
<h2>文档目录</h2>
|
||||
<div class="empty-list">
|
||||
<i class="fas fa-file-alt fa-3x"></i>
|
||||
<p>暂无文档</p>
|
||||
</div>
|
||||
`;
|
||||
docContentContainer.innerHTML = `
|
||||
<div class="empty-content">
|
||||
<i class="fas fa-file-alt fa-3x"></i>
|
||||
<h2>暂无文档</h2>
|
||||
<p>系统中还没有添加任何使用教程文档。</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建文档列表
|
||||
let html = '<h2>文档目录</h2><ul class="doc-list">';
|
||||
data.forEach((doc, index) => {
|
||||
// 确保doc有效
|
||||
if (doc && doc.id && doc.title) {
|
||||
html += `
|
||||
<li class="doc-item" data-id="${doc.id}">
|
||||
<a href="javascript:void(0)" onclick="showDocument(${index})">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<span>${doc.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
} else {
|
||||
console.warn('发现无效的文档数据:', doc);
|
||||
}
|
||||
});
|
||||
html += '</ul>';
|
||||
|
||||
docListContainer.innerHTML = html;
|
||||
|
||||
// 默认加载第一篇文档
|
||||
if (data.length > 0 && data[0]) {
|
||||
showDocument(0);
|
||||
// 激活第一个列表项
|
||||
const firstLink = docListContainer.querySelector('.doc-item a');
|
||||
if (firstLink) {
|
||||
firstLink.classList.add('active');
|
||||
}
|
||||
} else {
|
||||
// 如果第一个文档无效,显示空状态
|
||||
docContentContainer.innerHTML = `
|
||||
<div class="empty-content">
|
||||
<i class="fas fa-file-alt fa-3x"></i>
|
||||
<p>请从左侧选择一篇文档查看</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文档列表失败:', error);
|
||||
documentationLoaded = false; // 加载失败,允许重试
|
||||
|
||||
if (docListContainer) {
|
||||
docListContainer.innerHTML = `
|
||||
<h2>文档目录</h2>
|
||||
<div class="error-item">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>${error.message}</p>
|
||||
<button class="btn btn-sm btn-primary mt-2" onclick="loadAndDisplayDocumentation()">重试</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (docContentContainer) {
|
||||
docContentContainer.innerHTML = `
|
||||
<div class="error-container">
|
||||
<i class="fas fa-exclamation-triangle fa-3x"></i>
|
||||
<h2>加载失败</h2>
|
||||
<p>无法获取文档列表: ${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ========================================
|
||||
// === 文档加载相关函数结束 ===
|
||||
// ========================================
|
||||
|
||||
// ========================================
|
||||
// === 全局变量和状态 ===
|
||||
// ========================================
|
||||
let proxyDomain = '';
|
||||
let currentIndex = 0;
|
||||
let items = [];
|
||||
let currentPage = 1;
|
||||
@@ -215,6 +343,43 @@
|
||||
let currentTagPage = 1;
|
||||
let currentImageData = null;
|
||||
|
||||
// ========================================
|
||||
// === 新增:全局提示函数 ===
|
||||
// ========================================
|
||||
function showToastNotification(message, type = 'info') { // types: info, success, error
|
||||
// 移除任何现有的通知
|
||||
const existingNotification = document.querySelector('.toast-notification');
|
||||
if (existingNotification) {
|
||||
existingNotification.remove();
|
||||
}
|
||||
|
||||
// 创建新的通知元素
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-notification ${type}`;
|
||||
|
||||
// 设置图标和内容
|
||||
let iconClass = 'fas fa-info-circle';
|
||||
if (type === 'success') iconClass = 'fas fa-check-circle';
|
||||
if (type === 'error') iconClass = 'fas fa-exclamation-circle';
|
||||
|
||||
toast.innerHTML = `<i class="${iconClass}"></i> ${message}`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 动画效果 (如果需要的话,可以在CSS中定义 @keyframes fadeIn)
|
||||
// toast.style.animation = 'fadeIn 0.3s ease-in';
|
||||
|
||||
// 设定时间后自动移除
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0'; // 开始淡出
|
||||
toast.style.transition = 'opacity 0.3s ease-out';
|
||||
setTimeout(() => toast.remove(), 300); // 淡出后移除DOM
|
||||
}, 3500); // 显示 3.5 秒
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// === 其他函数定义 ===
|
||||
// ========================================
|
||||
// 标签切换功能
|
||||
function switchTab(tabName) {
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
@@ -252,7 +417,7 @@
|
||||
document.getElementById('paginationContainer').style.display = 'none';
|
||||
|
||||
if (tabName === 'documentation') {
|
||||
fetchDocumentation();
|
||||
loadAndDisplayDocumentation();
|
||||
} else if (tabName === 'accelerate') {
|
||||
// 重置显示状态
|
||||
document.querySelector('.quick-guide').style.display = 'block';
|
||||
@@ -414,7 +579,7 @@
|
||||
async function searchDockerHub(page = 1) {
|
||||
const searchTerm = document.getElementById('searchInput').value.trim();
|
||||
if (!searchTerm) {
|
||||
showToast('请输入搜索关键词');
|
||||
showToastNotification('请输入搜索关键词', 'info');
|
||||
return;
|
||||
}
|
||||
// 如果搜索词改变,重置为第1页
|
||||
@@ -436,6 +601,8 @@
|
||||
document.getElementById('imageTagsView').style.display = 'none';
|
||||
|
||||
try {
|
||||
console.log(`搜索Docker Hub: 关键词=${searchTerm}, 页码=${page}`);
|
||||
|
||||
// 使用新的fetchWithRetry函数
|
||||
const data = await fetchWithRetry(
|
||||
`/api/dockerhub/search?term=${encodeURIComponent(searchTerm)}&page=${page}`
|
||||
@@ -608,9 +775,6 @@
|
||||
<div class="tag-actions">
|
||||
<div class="tag-search-container">
|
||||
<input type="text" id="tagSearchInput" placeholder="搜索TAG..." onkeyup="filterTags()">
|
||||
<button class="search-btn" onclick="filterTags()">
|
||||
<i class="fas fa-search"></i> 搜索
|
||||
</button>
|
||||
</div>
|
||||
<button id="loadAllTagsBtn" class="load-all-btn" onclick="loadAllTags()" ${loadAllBtnDisabled ? 'disabled' : ''}>
|
||||
<i class="fas fa-cloud-download-alt"></i> 加载全部TAG
|
||||
@@ -652,25 +816,10 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 显示通知
|
||||
showToast(`<i class="fas fa-exclamation-circle"></i> 加载镜像详情失败: ${error.message}`, true);
|
||||
showToastNotification(`加载镜像详情失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 添加formatNumber函数定义
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000000) {
|
||||
return (num >= 1500000000 ? '1B+' : '1B');
|
||||
} else if (num >= 1000000) {
|
||||
const m = Math.floor(num / 1000000);
|
||||
return (m >= 100 ? '100M+' : m + 'M');
|
||||
} else if (num >= 1000) {
|
||||
const k = Math.floor(num / 1000);
|
||||
return (k >= 100 ? '100K+' : k + 'K');
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
// 新增: 加载所有标签 - 改进错误处理
|
||||
async function loadAllTags() {
|
||||
if (!currentImageData) {
|
||||
@@ -695,7 +844,7 @@
|
||||
|
||||
if (totalTags === 0) {
|
||||
tagsResults.innerHTML = '<div class="message-container"><i class="fas fa-info-circle"></i><p>未找到标签信息</p></div>';
|
||||
showToast(`<i class="fas fa-info-circle"></i> 该镜像没有可用的标签`, false);
|
||||
showToastNotification(`该镜像没有可用的标签`, 'info');
|
||||
loadAllTagsBtn.disabled = false;
|
||||
loadAllTagsBtn.innerHTML = '<i class="fas fa-cloud-download-alt"></i> 加载全部TAG';
|
||||
return;
|
||||
@@ -767,8 +916,7 @@
|
||||
// 显示第一页标签(这会自动创建分页控制器)
|
||||
displayAllTagsPage(1);
|
||||
|
||||
// 显示提示
|
||||
showToast(`<i class="fas fa-check-circle"></i> 成功加载 ${allTags.length} / ${totalTags} 个标签,分${clientTotalPages}页显示`, false);
|
||||
showToastNotification(`成功加载 ${allTags.length} / ${totalTags} 个标签,分${clientTotalPages}页显示`, 'success');
|
||||
|
||||
// 滚动到顶部
|
||||
window.scrollTo({
|
||||
@@ -778,7 +926,7 @@
|
||||
|
||||
} else {
|
||||
tagsResults.innerHTML = '<div class="message-container"><i class="fas fa-info-circle"></i><p>未找到标签信息</p></div>';
|
||||
showToast(`<i class="fas fa-info-circle"></i> 未能加载标签`, true);
|
||||
showToastNotification(`未能加载标签`, 'info');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -792,7 +940,7 @@
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
showToast(`<i class="fas fa-exclamation-circle"></i> 加载全部标签失败: ${error.message}`, true);
|
||||
showToastNotification(`加载全部标签失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
// 恢复按钮状态
|
||||
loadAllTagsBtn.disabled = false;
|
||||
@@ -859,8 +1007,7 @@
|
||||
`;
|
||||
document.getElementById('tagPaginationContainer').style.display = 'none';
|
||||
|
||||
// 显示通知
|
||||
showToast(`<i class="fas fa-exclamation-circle"></i> 加载标签失败: ${error.message}`, true);
|
||||
showToastNotification(`加载标签失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1300,189 +1447,223 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文档列表
|
||||
async function fetchDocumentation() {
|
||||
try {
|
||||
const response = await fetch('/api/documentation');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const documents = await response.json();
|
||||
console.log('Fetched documents:', documents);
|
||||
const documentList = document.getElementById('documentList');
|
||||
const documentationText = document.getElementById('documentationText');
|
||||
// 显示指定的文档
|
||||
function showDocument(index) {
|
||||
console.log('显示文档索引:', index);
|
||||
|
||||
if (Array.isArray(documents) && documents.length > 0) {
|
||||
documentList.innerHTML = '<h2>文档列表</h2>';
|
||||
const ul = document.createElement('ul');
|
||||
documents.forEach(doc => {
|
||||
if (doc && doc.id && doc.title) {
|
||||
const li = document.createElement('li');
|
||||
const link = document.createElement('a');
|
||||
link.href = 'javascript:void(0);';
|
||||
link.textContent = doc.title;
|
||||
link.onclick = () => {
|
||||
// 移除其他链接的active类
|
||||
document.querySelectorAll('#documentList a').forEach(a => a.classList.remove('active'));
|
||||
// 添加当前链接的active类
|
||||
if (!window.documentationData || !Array.isArray(window.documentationData)) {
|
||||
console.error('文档数据不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理数字索引或字符串ID
|
||||
let docIndex = index;
|
||||
let doc = null;
|
||||
|
||||
if (typeof index === 'string') {
|
||||
// 如果是ID,找到对应的索引
|
||||
docIndex = window.documentationData.findIndex(doc =>
|
||||
(doc.id === index || doc._id === index)
|
||||
);
|
||||
|
||||
if (docIndex === -1) {
|
||||
console.error('找不到ID为', index, '的文档');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
doc = window.documentationData[docIndex];
|
||||
|
||||
if (!doc) {
|
||||
console.error('指定索引的文档不存在:', docIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('文档数据:', doc);
|
||||
|
||||
// 高亮选中的文档
|
||||
const docLinks = document.querySelectorAll('.doc-list li a');
|
||||
docLinks.forEach((link, i) => {
|
||||
if (i === docIndex) {
|
||||
link.classList.add('active');
|
||||
showDocument(doc.id);
|
||||
};
|
||||
li.appendChild(link);
|
||||
ul.appendChild(li);
|
||||
} else {
|
||||
link.classList.remove('active');
|
||||
}
|
||||
});
|
||||
documentList.appendChild(ul);
|
||||
|
||||
// 默认选中第一个文档
|
||||
if (documents[0] && documents[0].id) {
|
||||
const firstLink = ul.querySelector('a');
|
||||
if (firstLink) {
|
||||
firstLink.classList.add('active');
|
||||
showDocument(documents[0].id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
documentationText.innerHTML = '暂无文档内容';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取文档列表失败:', error);
|
||||
document.getElementById('documentationText').innerHTML = '加载文档列表失败,请稍后再试。错误详情: ' + error.message;
|
||||
}
|
||||
const docContent = document.getElementById('documentationText');
|
||||
if (!docContent) {
|
||||
console.error('找不到文档内容容器');
|
||||
return;
|
||||
}
|
||||
|
||||
async function showDocument(id) {
|
||||
try {
|
||||
console.log('Attempting to show document with id:', id);
|
||||
const response = await fetch(`/api/documentation/${id}`);
|
||||
// 显示加载状态
|
||||
docContent.innerHTML = '<div class="loading-container"><i class="fas fa-spinner fa-spin"></i> 正在加载文档内容...</div>';
|
||||
|
||||
// 如果文档内容不存在,则需要获取完整内容
|
||||
if (!doc.content) {
|
||||
const docId = doc.id || doc._id;
|
||||
console.log('获取文档内容,ID:', docId);
|
||||
|
||||
fetch(`/api/documentation/${docId}`)
|
||||
.then(response => {
|
||||
console.log('文档API响应:', response.status, response.statusText);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
throw new Error(`获取文档内容失败: ${response.status}`);
|
||||
}
|
||||
const documentData = await response.json();
|
||||
console.log('Fetched document:', documentData);
|
||||
const documentationText = document.getElementById('documentationText');
|
||||
if (documentData && documentData.content && documentData.content.trim() !== '') {
|
||||
// 渲染文档内容
|
||||
documentationText.innerHTML = `<h2>${documentData.title || '无标题'}</h2>` + marked.parse(documentData.content);
|
||||
// 为所有代码块添加复制按钮和终端样式
|
||||
const codeBlocks = documentationText.querySelectorAll('pre code');
|
||||
codeBlocks.forEach((codeBlock) => {
|
||||
const pre = codeBlock.parentElement;
|
||||
const code = codeBlock.textContent;
|
||||
return response.json();
|
||||
})
|
||||
.then(fullDoc => {
|
||||
console.log('获取到完整文档:', fullDoc);
|
||||
|
||||
// 创建终端风格容器
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'command-terminal';
|
||||
wrapper.innerHTML = `
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-button button-red"></div>
|
||||
<div class="terminal-button button-yellow"></div>
|
||||
<div class="terminal-button button-green"></div>
|
||||
// 更新缓存的文档内容
|
||||
window.documentationData[docIndex].content = fullDoc.content;
|
||||
|
||||
// 渲染文档内容
|
||||
renderDocumentContent(docContent, fullDoc);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取文档内容失败:', error);
|
||||
docContent.innerHTML = `
|
||||
<div class="error-container">
|
||||
<i class="fas fa-exclamation-triangle fa-3x"></i>
|
||||
<h2>加载失败</h2>
|
||||
<p>无法获取文档内容: ${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 创建复制按钮
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.className = 'copy-btn';
|
||||
copyButton.textContent = '复制';
|
||||
copyButton.onclick = () => copyToClipboard(code, copyButton);
|
||||
|
||||
// 正确的DOM操作顺序
|
||||
pre.parentNode.insertBefore(wrapper, pre); // 1. 先将wrapper插入到pre前面
|
||||
wrapper.appendChild(pre); // 2. 将pre移动到wrapper内
|
||||
pre.appendChild(copyButton); // 3. 将复制按钮添加到pre内
|
||||
});
|
||||
} else {
|
||||
documentationText.innerHTML = '该文档没有内容或格式不正确';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取文档内容失败:', error);
|
||||
document.getElementById('documentationText').innerHTML = '加载文档内容失败,请稍后再试。错误详情: ' + error.message;
|
||||
// 直接渲染已有的文档内容
|
||||
renderDocumentContent(docContent, doc);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取并加载配置
|
||||
async function loadConfig() {
|
||||
// 确保showDocument函数在全局范围内可用
|
||||
window.showDocument = showDocument;
|
||||
|
||||
// 渲染文档内容
|
||||
function renderDocumentContent(container, doc) {
|
||||
if (!container) return;
|
||||
|
||||
console.log('正在渲染文档:', doc);
|
||||
|
||||
// 确保有内容可渲染
|
||||
if (!doc.content && !doc.path) {
|
||||
container.innerHTML = `
|
||||
<h1>${doc.title || '未知文档'}</h1>
|
||||
<div class="empty-content">
|
||||
<i class="fas fa-file-alt fa-3x"></i>
|
||||
<p>该文档暂无内容</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据文档内容类型进行渲染
|
||||
if (doc.content) {
|
||||
renderMarkdownContent(container, doc);
|
||||
} else {
|
||||
// 如果是文件路径但无内容,尝试获取
|
||||
fetch(`/api/documentation/file?path=${encodeURIComponent(doc.id + '.md')}`)
|
||||
.then(response => {
|
||||
console.log('文件内容响应:', response.status, response.statusText);
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取文件内容失败: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(content => {
|
||||
console.log('获取到文件内容,长度:', content.length);
|
||||
doc.content = content;
|
||||
renderMarkdownContent(container, doc);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取文件内容失败:', error);
|
||||
container.innerHTML = `
|
||||
<div class="error-container">
|
||||
<i class="fas fa-exclamation-triangle fa-3x"></i>
|
||||
<h2>加载失败</h2>
|
||||
<p>无法获取文档内容: ${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染Markdown内容
|
||||
function renderMarkdownContent(container, doc) {
|
||||
if (!container) return;
|
||||
|
||||
console.log('渲染Markdown内容:', doc.title, '内容长度:', doc.content ? doc.content.length : 0);
|
||||
|
||||
if (doc.content) {
|
||||
// 使用marked渲染Markdown内容
|
||||
if (window.marked) {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const config = await response.json();
|
||||
if (config.logo) {
|
||||
document.querySelector('.logo').src = config.logo;
|
||||
const parsedContent = marked.parse(doc.content);
|
||||
// 结构:内容(含标题)-> 元数据
|
||||
container.innerHTML = `
|
||||
<div class="doc-content">${parsedContent}</div>
|
||||
<div class="doc-meta">
|
||||
${doc.lastUpdated || doc.updatedAt ? `<span>最后更新: ${new Date(doc.lastUpdated || doc.updatedAt).toLocaleDateString('zh-CN')}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Markdown解析失败:', error);
|
||||
// 发生错误时,仍然显示原始Markdown内容 + Meta
|
||||
container.innerHTML = `
|
||||
<div class="doc-content">${doc.content}</div>
|
||||
<div class="doc-meta">
|
||||
${doc.lastUpdated || doc.updatedAt ? `<span>最后更新: ${new Date(doc.lastUpdated || doc.updatedAt).toLocaleDateString('zh-CN')}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (config.menuItems && Array.isArray(config.menuItems)) {
|
||||
const navMenu = document.getElementById('navMenu');
|
||||
navMenu.innerHTML = ''; // 清空菜单
|
||||
config.menuItems.forEach(item => {
|
||||
const a = document.createElement('a');
|
||||
a.href = item.link;
|
||||
a.textContent = item.text;
|
||||
if (item.newTab) {
|
||||
a.target = '_blank';
|
||||
} else {
|
||||
// marked 不可用时,直接显示内容 + Meta
|
||||
container.innerHTML = `
|
||||
<div class="doc-content">${doc.content}</div>
|
||||
<div class="doc-meta">
|
||||
${doc.lastUpdated || doc.updatedAt ? `<span>最后更新: ${new Date(doc.lastUpdated || doc.updatedAt).toLocaleDateString('zh-CN')}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
// 文档无内容时,显示占位符
|
||||
container.innerHTML = `
|
||||
<div class="doc-content">
|
||||
<div class="empty-content">
|
||||
<i class="fas fa-file-alt fa-3x"></i>
|
||||
<p>该文档暂无内容</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
<span>文档信息不可用</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载菜单
|
||||
loadMenu();
|
||||
|
||||
// DOMContentLoaded 事件监听器
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 确保元素存在再添加事件监听器
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keypress', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
searchDockerHub(1);
|
||||
}
|
||||
navMenu.appendChild(a);
|
||||
});
|
||||
}
|
||||
if (config.proxyDomain) {
|
||||
proxyDomain = config.proxyDomain;
|
||||
}
|
||||
if (config.searchApiEndpoint) {
|
||||
window.searchApiEndpoint = config.searchApiEndpoint;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function useImage(imageName) {
|
||||
if (imageName) {
|
||||
console.log("使用镜像:", imageName);
|
||||
document.getElementById('imageInput').value = imageName;
|
||||
switchTab('accelerate');
|
||||
// 直接生成加速命令,无需用户再次点击
|
||||
generateCommands(imageName);
|
||||
} else {
|
||||
alert('无效的 Docker 镜像名称');
|
||||
}
|
||||
}
|
||||
// 加载菜单
|
||||
loadMenu();
|
||||
|
||||
// 改进Toast通知功能,支持HTML内容
|
||||
function showToast(message, isError = false) {
|
||||
// 移除任何现有的提示
|
||||
const existingToasts = document.querySelectorAll('.toast-notification');
|
||||
existingToasts.forEach(toast => toast.remove());
|
||||
|
||||
// 创建新的提示
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-notification ${isError ? 'error' : 'info'}`;
|
||||
toast.innerHTML = message; // 使用innerHTML而不是textContent以支持HTML
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 3秒后自动消失
|
||||
setTimeout(() => {
|
||||
toast.classList.add('fade-out');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 5000); // 延长显示时间到5秒,给用户更多时间阅读
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
|
||||
// 添加缺失的showSearchResults函数
|
||||
function showSearchResults() {
|
||||
// 显示搜索结果列表,隐藏标签视图
|
||||
document.getElementById('searchResultsList').style.display = 'block';
|
||||
document.getElementById('imageTagsView').style.display = 'none';
|
||||
|
||||
// 清空标签搜索输入框
|
||||
const tagSearchInput = document.getElementById('tagSearchInput');
|
||||
if (tagSearchInput) {
|
||||
tagSearchInput.value = '';
|
||||
}
|
||||
|
||||
// 显示分页控件(如果有搜索结果)
|
||||
if (document.getElementById('searchResults').children.length > 0) {
|
||||
document.getElementById('paginationContainer').style.display = 'flex';
|
||||
}
|
||||
}
|
||||
// 统一调用文档加载函数
|
||||
loadAndDisplayDocumentation();
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/2.0.3/marked.min.js"></script>
|
||||
</body>
|
||||
|
||||
495
hubcmdui/web/js/app.js
Normal file
495
hubcmdui/web/js/app.js
Normal file
@@ -0,0 +1,495 @@
|
||||
// 应用程序入口模块
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM 加载完成,初始化模块...');
|
||||
|
||||
// 启动应用程序
|
||||
core.initApp();
|
||||
|
||||
// 在核心应用初始化后,再初始化其他模块
|
||||
initializeModules();
|
||||
console.log('模块初始化已启动');
|
||||
});
|
||||
|
||||
// 初始化所有模块
|
||||
async function initializeModules() {
|
||||
console.log('开始初始化所有模块...');
|
||||
try {
|
||||
// 初始化核心模块
|
||||
console.log('正在初始化核心模块...');
|
||||
if (typeof core !== 'undefined') {
|
||||
// core.init() 已经在core.initApp()中调用,这里不再重复调用
|
||||
console.log('核心模块初始化完成');
|
||||
} else {
|
||||
console.error('核心模块未定义');
|
||||
}
|
||||
|
||||
// 初始化认证模块
|
||||
console.log('正在初始化认证模块...');
|
||||
if (typeof auth !== 'undefined') {
|
||||
await auth.init();
|
||||
console.log('认证模块初始化完成');
|
||||
} else {
|
||||
console.error('认证模块未定义');
|
||||
}
|
||||
|
||||
// 初始化用户中心
|
||||
console.log('正在初始化用户中心...');
|
||||
if (typeof userCenter !== 'undefined') {
|
||||
await userCenter.init();
|
||||
console.log('用户中心初始化完成');
|
||||
} else {
|
||||
console.error('用户中心未定义');
|
||||
}
|
||||
|
||||
// 初始化菜单管理
|
||||
console.log('正在初始化菜单管理...');
|
||||
if (typeof menuManager !== 'undefined') {
|
||||
await menuManager.init();
|
||||
console.log('菜单管理初始化完成');
|
||||
} else {
|
||||
console.error('菜单管理未定义');
|
||||
}
|
||||
|
||||
// 初始化文档管理
|
||||
console.log('正在初始化文档管理...');
|
||||
if (typeof documentManager !== 'undefined') {
|
||||
await documentManager.init();
|
||||
console.log('文档管理初始化完成');
|
||||
} else {
|
||||
console.error('文档管理未定义');
|
||||
}
|
||||
|
||||
// 初始化Docker管理
|
||||
console.log('正在初始化Docker管理...');
|
||||
if (typeof dockerManager !== 'undefined') {
|
||||
await dockerManager.init();
|
||||
console.log('Docker管理初始化完成');
|
||||
} else {
|
||||
console.error('Docker管理未定义');
|
||||
}
|
||||
|
||||
// 初始化系统状态
|
||||
console.log('正在初始化系统状态...');
|
||||
if (typeof systemStatus !== 'undefined') {
|
||||
if (typeof systemStatus.initDashboard === 'function') {
|
||||
await systemStatus.initDashboard();
|
||||
console.log('系统状态初始化完成');
|
||||
} else {
|
||||
console.error('systemStatus.initDashboard 函数未定义!');
|
||||
}
|
||||
} else {
|
||||
console.error('系统状态未定义');
|
||||
}
|
||||
|
||||
// 初始化网络测试
|
||||
console.log('正在初始化网络测试...');
|
||||
if (typeof networkTest !== 'undefined') {
|
||||
await networkTest.init();
|
||||
console.log('网络测试初始化完成');
|
||||
} else {
|
||||
console.error('网络测试未定义');
|
||||
}
|
||||
|
||||
// 加载监控配置
|
||||
await loadMonitoringConfig();
|
||||
|
||||
// 显示默认页面 - 使用core中的showSection函数
|
||||
core.showSection('dashboard');
|
||||
|
||||
console.log('所有模块初始化完成');
|
||||
} catch (error) {
|
||||
console.error('初始化模块时发生错误:', error);
|
||||
// 尝试使用 core.showAlert,如果 core 本身加载失败则用 console.error
|
||||
if (typeof core !== 'undefined' && core.showAlert) {
|
||||
core.showAlert('初始化失败: ' + error.message, 'error');
|
||||
} else {
|
||||
console.error('核心模块无法加载,无法显示警告弹窗');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监控配置相关函数
|
||||
function loadMonitoringConfig() {
|
||||
console.log('正在加载监控配置...');
|
||||
fetch('/api/monitoring-config')
|
||||
.then(response => {
|
||||
console.log('监控配置API响应:', response.status, response.statusText);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP状态错误 ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(config => {
|
||||
console.log('获取到监控配置:', config);
|
||||
// 填充表单
|
||||
document.getElementById('notificationType').value = config.notificationType || 'wechat';
|
||||
document.getElementById('webhookUrl').value = config.webhookUrl || '';
|
||||
document.getElementById('telegramToken').value = config.telegramToken || '';
|
||||
document.getElementById('telegramChatId').value = config.telegramChatId || '';
|
||||
document.getElementById('monitorInterval').value = config.monitorInterval || 60;
|
||||
|
||||
// 显示或隐藏相应的字段
|
||||
toggleNotificationFields();
|
||||
|
||||
// 更新监控状态
|
||||
document.getElementById('monitoringStatus').textContent =
|
||||
config.isEnabled ? '已启用' : '已禁用';
|
||||
document.getElementById('monitoringStatus').style.color =
|
||||
config.isEnabled ? '#4CAF50' : '#F44336';
|
||||
|
||||
document.getElementById('toggleMonitoringBtn').textContent =
|
||||
config.isEnabled ? '禁用监控' : '启用监控';
|
||||
|
||||
console.log('监控配置加载完成');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('加载监控配置失败:', error);
|
||||
// 使用安全的方式调用core.showAlert
|
||||
if (typeof core !== 'undefined' && core && typeof core.showAlert === 'function') {
|
||||
core.showAlert('加载监控配置失败: ' + error.message, 'error');
|
||||
} else {
|
||||
// 如果core未定义,使用alert作为备选
|
||||
alert('加载监控配置失败: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNotificationFields() {
|
||||
const type = document.getElementById('notificationType').value;
|
||||
if (type === 'wechat') {
|
||||
document.getElementById('wechatFields').style.display = 'block';
|
||||
document.getElementById('telegramFields').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('wechatFields').style.display = 'none';
|
||||
document.getElementById('telegramFields').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function testNotification() {
|
||||
const notificationType = document.getElementById('notificationType').value;
|
||||
const webhookUrl = document.getElementById('webhookUrl').value;
|
||||
const telegramToken = document.getElementById('telegramToken').value;
|
||||
const telegramChatId = document.getElementById('telegramChatId').value;
|
||||
|
||||
// 验证输入
|
||||
if (notificationType === 'wechat' && !webhookUrl) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '验证失败',
|
||||
text: '请输入企业微信机器人 Webhook URL',
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '验证失败',
|
||||
text: '请输入 Telegram Bot Token 和 Chat ID',
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示处理中的状态
|
||||
Swal.fire({
|
||||
title: '发送中...',
|
||||
html: '<i class="fas fa-spinner fa-spin"></i> 正在发送测试通知',
|
||||
showConfirmButton: false,
|
||||
allowOutsideClick: false,
|
||||
willOpen: () => {
|
||||
Swal.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
fetch('/api/test-notification', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
notificationType,
|
||||
webhookUrl,
|
||||
telegramToken,
|
||||
telegramChatId
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('测试通知失败');
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: '发送成功',
|
||||
text: '测试通知已发送,请检查您的接收设备',
|
||||
timer: 2000,
|
||||
showConfirmButton: false
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('测试通知失败:', error);
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '发送失败',
|
||||
text: '测试通知发送失败: ' + error.message,
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveMonitoringConfig() {
|
||||
const notificationType = document.getElementById('notificationType').value;
|
||||
const webhookUrl = document.getElementById('webhookUrl').value;
|
||||
const telegramToken = document.getElementById('telegramToken').value;
|
||||
const telegramChatId = document.getElementById('telegramChatId').value;
|
||||
const monitorInterval = document.getElementById('monitorInterval').value;
|
||||
|
||||
// 验证输入
|
||||
if (notificationType === 'wechat' && !webhookUrl) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '验证失败',
|
||||
text: '请输入企业微信机器人 Webhook URL',
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '验证失败',
|
||||
text: '请输入 Telegram Bot Token 和 Chat ID',
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示保存中的状态
|
||||
Swal.fire({
|
||||
title: '保存中...',
|
||||
html: '<i class="fas fa-spinner fa-spin"></i> 正在保存监控配置',
|
||||
showConfirmButton: false,
|
||||
allowOutsideClick: false,
|
||||
willOpen: () => {
|
||||
Swal.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
fetch('/api/monitoring-config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
notificationType,
|
||||
webhookUrl,
|
||||
telegramToken,
|
||||
telegramChatId,
|
||||
monitorInterval,
|
||||
isEnabled: document.getElementById('monitoringStatus').textContent === '已启用'
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('保存配置失败');
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: '保存成功',
|
||||
text: '监控配置已成功保存',
|
||||
timer: 2000,
|
||||
showConfirmButton: false
|
||||
});
|
||||
loadMonitoringConfig();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('保存监控配置失败:', error);
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '保存失败',
|
||||
text: '保存监控配置失败: ' + error.message,
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleMonitoring() {
|
||||
const isCurrentlyEnabled = document.getElementById('monitoringStatus').textContent === '已启用';
|
||||
const newStatus = !isCurrentlyEnabled ? '启用' : '禁用';
|
||||
|
||||
Swal.fire({
|
||||
title: `确认${newStatus}监控?`,
|
||||
html: `
|
||||
<div style="text-align: left; margin-top: 10px;">
|
||||
<p>您确定要<strong>${newStatus}</strong>容器监控系统吗?</p>
|
||||
${isCurrentlyEnabled ?
|
||||
'<p><i class="fas fa-exclamation-triangle" style="color: #f39c12;"></i> 禁用后,系统将停止监控容器状态并停止发送通知。</p>' :
|
||||
'<p><i class="fas fa-info-circle" style="color: #3498db;"></i> 启用后,系统将开始定期检查容器状态并在发现异常时发送通知。</p>'}
|
||||
</div>
|
||||
`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: isCurrentlyEnabled ? '#d33' : '#3085d6',
|
||||
cancelButtonColor: '#6c757d',
|
||||
confirmButtonText: `确认${newStatus}`,
|
||||
cancelButtonText: '取消'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// 显示处理中状态
|
||||
Swal.fire({
|
||||
title: '处理中...',
|
||||
html: `<i class="fas fa-spinner fa-spin"></i> 正在${newStatus}监控`,
|
||||
showConfirmButton: false,
|
||||
allowOutsideClick: false,
|
||||
willOpen: () => {
|
||||
Swal.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
fetch('/api/toggle-monitoring', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
isEnabled: !isCurrentlyEnabled
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('切换监控状态失败');
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
loadMonitoringConfig();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: `${newStatus}成功`,
|
||||
text: `监控已成功${newStatus}`,
|
||||
timer: 2000,
|
||||
showConfirmButton: false
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('切换监控状态失败:', error);
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: `${newStatus}失败`,
|
||||
text: '切换监控状态失败: ' + error.message,
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshStoppedContainers() {
|
||||
fetch('/api/stopped-containers')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('获取已停止容器列表失败');
|
||||
return response.json();
|
||||
})
|
||||
.then(containers => {
|
||||
const tbody = document.getElementById('stoppedContainersBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (containers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">没有已停止的容器</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
containers.forEach(container => {
|
||||
const row = `
|
||||
<tr>
|
||||
<td>${container.id}</td>
|
||||
<td>${container.name}</td>
|
||||
<td>${container.status}</td>
|
||||
</tr>
|
||||
`;
|
||||
tbody.innerHTML += row;
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取已停止容器列表失败:', error);
|
||||
document.getElementById('stoppedContainersBody').innerHTML =
|
||||
'<tr><td colspan="3" style="text-align: center; color: red;">获取已停止容器列表失败</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
// 保存配置函数
|
||||
function saveConfig(configData) {
|
||||
core.showLoading();
|
||||
|
||||
fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(configData)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
throw new Error(`保存配置失败: ${text || response.statusText || response.status}`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
core.showAlert('配置已保存', 'success');
|
||||
// 如果更新了菜单,重新加载菜单项
|
||||
if (configData.menuItems) {
|
||||
menuManager.loadMenuItems();
|
||||
}
|
||||
// 重新加载系统配置
|
||||
core.loadSystemConfig();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('保存配置失败:', error);
|
||||
core.showAlert('保存配置失败: ' + error.message, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
core.hideLoading();
|
||||
});
|
||||
}
|
||||
|
||||
// 加载基本配置
|
||||
function loadBasicConfig() {
|
||||
fetch('/api/config')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('加载配置失败');
|
||||
return response.json();
|
||||
})
|
||||
.then(config => {
|
||||
// 填充Logo URL
|
||||
if (document.getElementById('logoUrl')) {
|
||||
document.getElementById('logoUrl').value = config.logo || '';
|
||||
}
|
||||
|
||||
// 填充代理域名
|
||||
if (document.getElementById('proxyDomain')) {
|
||||
document.getElementById('proxyDomain').value = config.proxyDomain || '';
|
||||
}
|
||||
|
||||
console.log('基本配置已加载');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('加载基本配置失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// 暴露给全局作用域的函数
|
||||
window.app = {
|
||||
loadMonitoringConfig,
|
||||
loadBasicConfig,
|
||||
toggleNotificationFields,
|
||||
saveMonitoringConfig,
|
||||
testNotification,
|
||||
toggleMonitoring,
|
||||
refreshStoppedContainers,
|
||||
saveConfig
|
||||
};
|
||||
124
hubcmdui/web/js/auth.js
Normal file
124
hubcmdui/web/js/auth.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// 用户认证相关功能
|
||||
|
||||
// 登录函数
|
||||
async function login() {
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const captcha = document.getElementById('captcha').value;
|
||||
|
||||
try {
|
||||
core.showLoading();
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, captcha })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
window.isLoggedIn = true;
|
||||
localStorage.setItem('isLoggedIn', 'true');
|
||||
persistSession();
|
||||
document.getElementById('currentUsername').textContent = username;
|
||||
document.getElementById('welcomeUsername').textContent = username;
|
||||
document.getElementById('loginModal').style.display = 'none';
|
||||
document.getElementById('adminContainer').style.display = 'flex';
|
||||
|
||||
// 确保加载完成后初始化事件监听器
|
||||
await core.loadSystemConfig();
|
||||
core.initEventListeners();
|
||||
core.showSection('dashboard');
|
||||
userCenter.getUserInfo();
|
||||
systemStatus.refreshSystemStatus();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
core.showAlert(errorData.error || '登录失败', 'error');
|
||||
refreshCaptcha();
|
||||
}
|
||||
} catch (error) {
|
||||
core.showAlert('登录失败: ' + error.message, 'error');
|
||||
refreshCaptcha();
|
||||
} finally {
|
||||
core.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 注销函数
|
||||
async function logout() {
|
||||
console.log("注销操作被触发");
|
||||
try {
|
||||
core.showLoading();
|
||||
const response = await fetch('/api/logout', { method: 'POST' });
|
||||
if (response.ok) {
|
||||
// 清除所有登录状态
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
sessionStorage.removeItem('sessionActive');
|
||||
window.isLoggedIn = false;
|
||||
// 清除cookie
|
||||
document.cookie = 'connect.sid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
window.location.reload();
|
||||
} else {
|
||||
throw new Error('退出登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
core.showAlert('退出登录失败: ' + error.message, 'error');
|
||||
// 即使API失败也清除本地状态
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
sessionStorage.removeItem('sessionActive');
|
||||
window.isLoggedIn = false;
|
||||
window.location.reload();
|
||||
} finally {
|
||||
core.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码刷新函数
|
||||
async function refreshCaptcha() {
|
||||
try {
|
||||
const response = await fetch('/api/captcha');
|
||||
if (!response.ok) {
|
||||
throw new Error(`验证码获取失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
document.getElementById('captchaText').textContent = data.captcha;
|
||||
} catch (error) {
|
||||
console.error('刷新验证码失败:', error);
|
||||
document.getElementById('captchaText').textContent = '验证码加载失败,点击重试';
|
||||
}
|
||||
}
|
||||
|
||||
// 持久化会话
|
||||
function persistSession() {
|
||||
if (document.cookie.includes('connect.sid')) {
|
||||
sessionStorage.setItem('sessionActive', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示登录模态框
|
||||
function showLoginModal() {
|
||||
// 确保先隐藏加载指示器
|
||||
if (core && typeof core.hideLoadingIndicator === 'function') {
|
||||
core.hideLoadingIndicator();
|
||||
}
|
||||
|
||||
document.getElementById('loginModal').style.display = 'flex';
|
||||
refreshCaptcha();
|
||||
}
|
||||
|
||||
// 导出模块
|
||||
const auth = {
|
||||
init: function() {
|
||||
console.log('初始化认证模块...');
|
||||
// 在这里可以添加认证模块初始化的相关代码
|
||||
return Promise.resolve(); // 返回一个已解决的 Promise,保持与其他模块一致
|
||||
},
|
||||
login,
|
||||
logout,
|
||||
refreshCaptcha,
|
||||
showLoginModal
|
||||
};
|
||||
|
||||
// 全局公开认证模块
|
||||
window.auth = auth;
|
||||
499
hubcmdui/web/js/core.js
Normal file
499
hubcmdui/web/js/core.js
Normal file
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* 核心功能模块
|
||||
* 提供全局共享的工具函数和状态管理
|
||||
*/
|
||||
|
||||
// 全局变量和状态
|
||||
let isLoggedIn = false;
|
||||
let userPermissions = [];
|
||||
let systemConfig = {};
|
||||
|
||||
/**
|
||||
* 初始化应用
|
||||
* 检查登录状态,加载基础配置
|
||||
*/
|
||||
async function initApp() {
|
||||
console.log('初始化应用...');
|
||||
console.log('-------------调试信息开始-------------');
|
||||
console.log('当前URL:', window.location.href);
|
||||
console.log('浏览器信息:', navigator.userAgent);
|
||||
console.log('DOM已加载状态:', document.readyState);
|
||||
|
||||
// 检查当前页面是否为登录页
|
||||
const isLoginPage = window.location.pathname.includes('admin');
|
||||
console.log('是否为管理页面:', isLoginPage);
|
||||
|
||||
try {
|
||||
// 检查会话状态
|
||||
const sessionResult = await checkSession();
|
||||
const isAuthenticated = sessionResult.authenticated;
|
||||
console.log('会话检查结果:', isAuthenticated);
|
||||
|
||||
// 检查localStorage中的登录状态 (主要用于刷新页面时保持UI)
|
||||
const localLoginState = localStorage.getItem('isLoggedIn') === 'true';
|
||||
|
||||
// 核心登录状态判断
|
||||
if (isAuthenticated) {
|
||||
// 已登录
|
||||
isLoggedIn = true;
|
||||
localStorage.setItem('isLoggedIn', 'true'); // 保持本地状态
|
||||
|
||||
if (isLoginPage) {
|
||||
// 在登录页,但会话有效,显示管理界面
|
||||
console.log('已登录,显示管理界面...');
|
||||
await loadSystemConfig();
|
||||
showAdminInterface();
|
||||
} else {
|
||||
// 在非登录页,正常显示
|
||||
console.log('已登录,继续应用初始化...');
|
||||
await loadSystemConfig();
|
||||
showAdminInterface(); // 确保管理界面可见
|
||||
}
|
||||
} else {
|
||||
// 未登录
|
||||
isLoggedIn = false;
|
||||
localStorage.removeItem('isLoggedIn'); // 清除本地登录状态
|
||||
|
||||
if (!isLoginPage) {
|
||||
// 在非登录页,重定向到登录页
|
||||
console.log('未登录,重定向到登录页...');
|
||||
window.location.href = '/admin';
|
||||
return false;
|
||||
} else {
|
||||
// 在登录页,显示登录框
|
||||
console.log('未登录,显示登录模态框...');
|
||||
hideLoadingIndicator();
|
||||
showLoginModal();
|
||||
}
|
||||
}
|
||||
|
||||
console.log('应用初始化完成');
|
||||
console.log('-------------调试信息结束-------------');
|
||||
return isAuthenticated;
|
||||
} catch (error) {
|
||||
console.error('初始化应用失败:', error);
|
||||
console.log('-------------调试错误信息-------------');
|
||||
console.log('错误堆栈:', error.stack);
|
||||
console.log('错误类型:', error.name);
|
||||
console.log('错误消息:', error.message);
|
||||
console.log('---------------------------------------');
|
||||
showAlert('加载应用失败:' + error.message, 'error');
|
||||
hideLoadingIndicator();
|
||||
showLoginModal();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查会话状态
|
||||
*/
|
||||
async function checkSession() {
|
||||
try {
|
||||
const response = await fetch('/api/check-session', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Pragma': 'no-cache'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
// 只关心请求是否成功以及认证状态
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
authenticated: data.authenticated // 直接使用API返回的状态
|
||||
};
|
||||
}
|
||||
|
||||
// 非OK响应(包括401)都视为未认证
|
||||
return {
|
||||
authenticated: false
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('检查会话状态出错:', error);
|
||||
return {
|
||||
authenticated: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载系统配置
|
||||
*/
|
||||
function loadSystemConfig() {
|
||||
fetch('/api/config')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
throw new Error(`加载配置失败: ${text || response.statusText || response.status}`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(config => {
|
||||
console.log('加载配置成功:', config);
|
||||
// 应用配置
|
||||
applySystemConfig(config);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('加载配置失败:', error);
|
||||
showAlert('加载配置失败: ' + error.message, 'warning');
|
||||
});
|
||||
}
|
||||
|
||||
// 应用系统配置
|
||||
function applySystemConfig(config) {
|
||||
// 如果有proxyDomain配置,则更新输入框
|
||||
if (config.proxyDomain && document.getElementById('proxyDomain')) {
|
||||
document.getElementById('proxyDomain').value = config.proxyDomain;
|
||||
}
|
||||
|
||||
// 应用其他配置...
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示管理界面
|
||||
*/
|
||||
function showAdminInterface() {
|
||||
console.log('开始显示管理界面...');
|
||||
hideLoadingIndicator();
|
||||
|
||||
const adminContainer = document.getElementById('adminContainer');
|
||||
if (adminContainer) {
|
||||
console.log('找到管理界面容器,设置为显示');
|
||||
adminContainer.style.display = 'flex';
|
||||
} else {
|
||||
console.error('未找到管理界面容器元素 #adminContainer');
|
||||
}
|
||||
|
||||
console.log('管理界面已显示,正在初始化事件监听器');
|
||||
|
||||
// 初始化菜单事件监听
|
||||
initEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏加载提示器
|
||||
*/
|
||||
function hideLoadingIndicator() {
|
||||
console.log('正在隐藏加载提示器...');
|
||||
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
console.log('加载提示器已隐藏');
|
||||
} else {
|
||||
console.warn('未找到加载提示器元素 #loadingIndicator');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示登录模态框
|
||||
*/
|
||||
function showLoginModal() {
|
||||
const loginModal = document.getElementById('loginModal');
|
||||
if (loginModal) {
|
||||
loginModal.style.display = 'flex';
|
||||
// 刷新验证码
|
||||
if (window.auth && typeof window.auth.refreshCaptcha === 'function') {
|
||||
window.auth.refreshCaptcha();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示加载动画
|
||||
*/
|
||||
function showLoading() {
|
||||
const loadingSpinner = document.getElementById('loadingSpinner');
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏加载动画
|
||||
*/
|
||||
function hideLoading() {
|
||||
const loadingSpinner = document.getElementById('loadingSpinner');
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示警告消息
|
||||
* @param {string} message - 消息内容
|
||||
* @param {string} type - 消息类型 (info, success, error)
|
||||
* @param {string} title - 标题(可选)
|
||||
*/
|
||||
function showAlert(message, type = 'info', title = '') {
|
||||
// 使用SweetAlert2替代自定义警告框,确保弹窗总是显示
|
||||
Swal.fire({
|
||||
title: title || (type === 'success' ? '成功' : (type === 'error' ? '错误' : '提示')),
|
||||
text: message,
|
||||
icon: type,
|
||||
timer: type === 'success' ? 2000 : undefined,
|
||||
timerProgressBar: type === 'success',
|
||||
confirmButtonColor: '#3d7cf4',
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示确认对话框
|
||||
* @param {string} message - 消息内容
|
||||
* @param {Function} onConfirm - 确认回调
|
||||
* @param {Function} onCancel - 取消回调(可选)
|
||||
* @param {string} title - 标题(可选)
|
||||
*/
|
||||
function showConfirm(message, onConfirm, onCancel, title = '确认') {
|
||||
Swal.fire({
|
||||
title: title,
|
||||
text: message,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#3d7cf4',
|
||||
cancelButtonColor: '#6c757d',
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed && typeof onConfirm === 'function') {
|
||||
onConfirm();
|
||||
} else if (typeof onCancel === 'function') {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
function formatDateTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数:限制函数在一定时间内只能执行一次
|
||||
*/
|
||||
function debounce(func, wait = 300) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func.apply(this, args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数:保证一定时间内多次调用只执行一次
|
||||
*/
|
||||
function throttle(func, wait = 300) {
|
||||
let timeout = null;
|
||||
let previous = 0;
|
||||
|
||||
return function(...args) {
|
||||
const now = Date.now();
|
||||
const remaining = wait - (now - previous);
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
previous = now;
|
||||
func.apply(this, args);
|
||||
} else if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
previous = Date.now();
|
||||
timeout = null;
|
||||
func.apply(this, args);
|
||||
}, remaining);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化事件监听
|
||||
*/
|
||||
function initEventListeners() {
|
||||
console.log('开始初始化事件监听器...');
|
||||
|
||||
// 侧边栏菜单切换事件
|
||||
const menuItems = document.querySelectorAll('.sidebar-nav li');
|
||||
console.log('找到侧边栏菜单项数量:', menuItems.length);
|
||||
|
||||
if (menuItems.length > 0) {
|
||||
menuItems.forEach((item, index) => {
|
||||
const sectionId = item.getAttribute('data-section');
|
||||
console.log(`绑定事件到菜单项 #${index+1}: ${sectionId}`);
|
||||
item.addEventListener('click', function() {
|
||||
const sectionId = this.getAttribute('data-section');
|
||||
showSection(sectionId);
|
||||
});
|
||||
});
|
||||
console.log('侧边栏菜单事件监听器已绑定');
|
||||
} else {
|
||||
console.error('未找到侧边栏菜单项 .sidebar-nav li');
|
||||
}
|
||||
|
||||
// 用户中心按钮
|
||||
const userCenterBtn = document.getElementById('userCenterBtn');
|
||||
if (userCenterBtn) {
|
||||
console.log('找到用户中心按钮,绑定事件');
|
||||
userCenterBtn.addEventListener('click', () => showSection('user-center'));
|
||||
} else {
|
||||
console.warn('未找到用户中心按钮 #userCenterBtn');
|
||||
}
|
||||
|
||||
// 登出按钮
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
console.log('找到登出按钮,绑定事件');
|
||||
logoutBtn.addEventListener('click', () => auth.logout());
|
||||
} else {
|
||||
console.warn('未找到登出按钮 #logoutBtn');
|
||||
}
|
||||
|
||||
// 用户中心内登出按钮
|
||||
const ucLogoutBtn = document.getElementById('ucLogoutBtn');
|
||||
if (ucLogoutBtn) {
|
||||
console.log('找到用户中心内登出按钮,绑定事件');
|
||||
ucLogoutBtn.addEventListener('click', () => auth.logout());
|
||||
} else {
|
||||
console.warn('未找到用户中心内登出按钮 #ucLogoutBtn');
|
||||
}
|
||||
|
||||
console.log('事件监听器初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示指定的内容区域
|
||||
* @param {string} sectionId 要显示的内容区域ID
|
||||
*/
|
||||
function showSection(sectionId) {
|
||||
console.log(`尝试显示内容区域: ${sectionId}`);
|
||||
|
||||
// 获取所有内容区域和菜单项
|
||||
const contentSections = document.querySelectorAll('.content-section');
|
||||
const menuItems = document.querySelectorAll('.sidebar-nav li');
|
||||
|
||||
console.log(`找到 ${contentSections.length} 个内容区域和 ${menuItems.length} 个菜单项`);
|
||||
|
||||
let sectionFound = false;
|
||||
let menuItemFound = false;
|
||||
|
||||
// 隐藏所有内容区域,取消激活所有菜单项
|
||||
contentSections.forEach(section => {
|
||||
section.classList.remove('active');
|
||||
});
|
||||
|
||||
menuItems.forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 激活指定的内容区域
|
||||
const targetSection = document.getElementById(sectionId);
|
||||
if (targetSection) {
|
||||
targetSection.classList.add('active');
|
||||
sectionFound = true;
|
||||
console.log(`成功激活内容区域: ${sectionId}`);
|
||||
|
||||
// 特殊处理:切换到用户中心时,确保用户信息已加载
|
||||
if (sectionId === 'user-center' && window.userCenter) {
|
||||
console.log('切换到用户中心,调用 getUserInfo()');
|
||||
window.userCenter.getUserInfo();
|
||||
}
|
||||
} else {
|
||||
console.error(`未找到指定的内容区域: ${sectionId}`);
|
||||
}
|
||||
|
||||
// 激活相应的菜单项
|
||||
const targetMenuItem = document.querySelector(`.sidebar-nav li[data-section="${sectionId}"]`);
|
||||
if (targetMenuItem) {
|
||||
targetMenuItem.classList.add('active');
|
||||
menuItemFound = true;
|
||||
console.log(`成功激活菜单项: ${sectionId}`);
|
||||
} else {
|
||||
console.error(`未找到对应的菜单项: ${sectionId}`);
|
||||
}
|
||||
|
||||
// 如果没有找到指定的内容区域和菜单项,显示仪表盘
|
||||
if (!sectionFound && !menuItemFound) {
|
||||
console.warn(`未找到指定的内容区域和菜单项,将显示默认仪表盘`);
|
||||
const dashboard = document.getElementById('dashboard');
|
||||
if (dashboard) {
|
||||
dashboard.classList.add('active');
|
||||
const dashboardMenuItem = document.querySelector('.sidebar-nav li[data-section="dashboard"]');
|
||||
if (dashboardMenuItem) {
|
||||
dashboardMenuItem.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换内容区域后可能需要执行的额外操作
|
||||
if (sectionId === 'dashboard') {
|
||||
console.log('已激活仪表盘,无需再次刷新系统状态');
|
||||
// 不再自动刷新系统状态,仅在首次加载或用户手动点击刷新按钮时刷新
|
||||
}
|
||||
|
||||
console.log(`内容区域切换完成: ${sectionId}`);
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM已加载,正在初始化应用...');
|
||||
initApp();
|
||||
|
||||
// 检查URL参数,处理消息提示等
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// 如果有message参数,显示相应的提示
|
||||
if (urlParams.has('message')) {
|
||||
const message = urlParams.get('message');
|
||||
let type = 'info';
|
||||
|
||||
if (urlParams.has('type')) {
|
||||
type = urlParams.get('type');
|
||||
}
|
||||
|
||||
showAlert(message, type);
|
||||
}
|
||||
});
|
||||
|
||||
// 导出核心对象
|
||||
const core = {
|
||||
isLoggedIn,
|
||||
initApp,
|
||||
checkSession,
|
||||
loadSystemConfig,
|
||||
applySystemConfig,
|
||||
showLoading,
|
||||
hideLoading,
|
||||
hideLoadingIndicator,
|
||||
showLoginModal,
|
||||
showAlert,
|
||||
showConfirm,
|
||||
formatDateTime,
|
||||
debounce,
|
||||
throttle,
|
||||
initEventListeners,
|
||||
showSection
|
||||
};
|
||||
|
||||
// 全局公开核心模块
|
||||
window.core = core;
|
||||
681
hubcmdui/web/js/dockerManager.js
Normal file
681
hubcmdui/web/js/dockerManager.js
Normal file
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* Docker管理模块 - 专注于 Docker 容器表格的渲染和交互
|
||||
*/
|
||||
|
||||
const dockerManager = {
|
||||
// 初始化函数 - 只做基本的 UI 设置或事件监听(如果需要)
|
||||
init: function() {
|
||||
// 减少日志输出
|
||||
// console.log('[dockerManager] Initializing Docker manager UI components...');
|
||||
|
||||
// 可以在这里添加下拉菜单的全局事件监听器等
|
||||
this.setupActionDropdownListener();
|
||||
|
||||
// 立即显示加载状态和表头
|
||||
this.showLoadingState();
|
||||
|
||||
// 添加对Bootstrap下拉菜单的初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.initDropdowns();
|
||||
});
|
||||
|
||||
// 当文档已经加载完成时立即初始化
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
this.initDropdowns();
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
// 初始化Bootstrap下拉菜单组件
|
||||
initDropdowns: function() {
|
||||
// 减少日志输出
|
||||
// console.log('[dockerManager] Initializing Bootstrap dropdowns...');
|
||||
|
||||
// 直接初始化,不使用setTimeout避免延迟导致的问题
|
||||
try {
|
||||
// 动态初始化所有下拉菜单
|
||||
const dropdownElements = document.querySelectorAll('[data-bs-toggle="dropdown"]');
|
||||
if (dropdownElements.length === 0) {
|
||||
return; // 如果没有找到下拉元素,直接返回
|
||||
}
|
||||
|
||||
if (window.bootstrap && window.bootstrap.Dropdown) {
|
||||
dropdownElements.forEach(el => {
|
||||
try {
|
||||
new window.bootstrap.Dropdown(el);
|
||||
} catch (e) {
|
||||
// 静默处理错误,不要输出到控制台
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('Bootstrap Dropdown 组件未找到,将尝试使用jQuery初始化');
|
||||
// 尝试使用jQuery初始化(如果存在)
|
||||
if (window.jQuery) {
|
||||
window.jQuery('[data-bs-toggle="dropdown"]').dropdown();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
}
|
||||
},
|
||||
|
||||
// 显示表格加载状态 - 保持,用于初始渲染和刷新
|
||||
showLoadingState() {
|
||||
const table = document.getElementById('dockerStatusTable');
|
||||
const tbody = document.getElementById('dockerStatusTableBody');
|
||||
|
||||
// 首先创建表格标题区域(如果不存在)
|
||||
let tableContainer = document.getElementById('dockerTableContainer');
|
||||
if (tableContainer) {
|
||||
// 添加表格标题区域 - 只有不存在时才添加
|
||||
if (!tableContainer.querySelector('.docker-table-header')) {
|
||||
const tableHeader = document.createElement('div');
|
||||
tableHeader.className = 'docker-table-header';
|
||||
tableHeader.innerHTML = `
|
||||
<h2 class="docker-table-title">Docker 容器管理</h2>
|
||||
<div class="docker-table-actions">
|
||||
<button id="refreshDockerBtn" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-sync-alt me-1"></i> 刷新列表
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 插入到表格前面
|
||||
if (table) {
|
||||
tableContainer.insertBefore(tableHeader, table);
|
||||
|
||||
// 添加刷新按钮事件
|
||||
const refreshBtn = document.getElementById('refreshDockerBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
if (window.systemStatus && typeof window.systemStatus.refreshSystemStatus === 'function') {
|
||||
window.systemStatus.refreshSystemStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (table && tbody) {
|
||||
// 添加Excel风格表格类
|
||||
table.classList.add('excel-table');
|
||||
|
||||
// 确保表头存在并正确渲染
|
||||
const thead = table.querySelector('thead');
|
||||
if (thead) {
|
||||
thead.innerHTML = `
|
||||
<tr>
|
||||
<th style="width: 120px;">容器ID</th>
|
||||
<th style="width: 25%;">容器名称</th>
|
||||
<th style="width: 35%;">镜像名称</th>
|
||||
<th style="width: 100px;">运行状态</th>
|
||||
<th style="width: 150px;">操作</th>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
tbody.innerHTML = `
|
||||
<tr class="loading-container">
|
||||
<td colspan="5">
|
||||
<div class="loading-animation">
|
||||
<div class="spinner"></div>
|
||||
<p>正在加载容器列表...</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
},
|
||||
|
||||
// 渲染容器表格 - 核心渲染函数,由 systemStatus 调用
|
||||
renderContainersTable(containers, dockerStatus) {
|
||||
// 减少详细日志输出
|
||||
// console.log(`[dockerManager] Rendering containers table. Containers count: ${containers ? containers.length : 0}`);
|
||||
|
||||
const tbody = document.getElementById('dockerStatusTableBody');
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保表头存在 (showLoadingState 应该已经创建)
|
||||
const table = document.getElementById('dockerStatusTable');
|
||||
if (table) {
|
||||
const thead = table.querySelector('thead');
|
||||
if (!thead || !thead.querySelector('tr')) {
|
||||
// 重新创建表头
|
||||
const newThead = thead || document.createElement('thead');
|
||||
newThead.innerHTML = `
|
||||
<tr>
|
||||
<th style="width: 120px;">容器ID</th>
|
||||
<th style="width: 25%;">容器名称</th>
|
||||
<th style="width: 35%;">镜像名称</th>
|
||||
<th style="width: 100px;">运行状态</th>
|
||||
<th style="width: 150px;">操作</th>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
if (!thead) {
|
||||
table.insertBefore(newThead, tbody);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 检查 Docker 服务状态
|
||||
if (dockerStatus !== 'running') {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-4">
|
||||
<i class="fab fa-docker fa-lg me-2"></i> Docker 服务未运行
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 检查容器数组是否有效且有内容
|
||||
if (!Array.isArray(containers) || containers.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-4">
|
||||
<i class="fas fa-info-circle me-2"></i> 暂无运行中的Docker容器
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 渲染容器列表
|
||||
let html = '';
|
||||
containers.forEach(container => {
|
||||
const status = container.State || container.status || '未知';
|
||||
const statusClass = this.getContainerStatusClass(status);
|
||||
const containerId = container.Id || container.id || '未知';
|
||||
const containerName = container.Names?.[0]?.substring(1) || container.name || '未知';
|
||||
const containerImage = container.Image || container.image || '未知';
|
||||
|
||||
// 添加lowerStatus变量定义,修复错误
|
||||
const lowerStatus = status.toLowerCase();
|
||||
|
||||
// 替换下拉菜单实现为直接的操作按钮
|
||||
let actionButtons = '';
|
||||
|
||||
// 基本操作:查看日志和详情
|
||||
actionButtons += `
|
||||
<button class="btn btn-sm btn-outline-info mb-1 mr-1 action-logs" data-id="${containerId}" data-name="${containerName}">
|
||||
<i class="fas fa-file-alt"></i> 日志
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary mb-1 mr-1 action-details" data-id="${containerId}">
|
||||
<i class="fas fa-info-circle"></i> 详情
|
||||
</button>
|
||||
`;
|
||||
|
||||
// 根据状态显示不同操作
|
||||
if (lowerStatus.includes('running')) {
|
||||
actionButtons += `
|
||||
<button class="btn btn-sm btn-outline-warning mb-1 mr-1 action-stop" data-id="${containerId}">
|
||||
<i class="fas fa-stop"></i> 停止
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary mb-1 mr-1 action-restart" data-id="${containerId}">
|
||||
<i class="fas fa-sync-alt"></i> 重启
|
||||
</button>
|
||||
`;
|
||||
} else if (lowerStatus.includes('exited') || lowerStatus.includes('stopped') || lowerStatus.includes('created')) {
|
||||
actionButtons += `
|
||||
<button class="btn btn-sm btn-outline-success mb-1 mr-1 action-start" data-id="${containerId}">
|
||||
<i class="fas fa-play"></i> 启动
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger mb-1 mr-1 action-remove" data-id="${containerId}">
|
||||
<i class="fas fa-trash-alt"></i> 删除
|
||||
</button>
|
||||
`;
|
||||
} else if (lowerStatus.includes('paused')) {
|
||||
actionButtons += `
|
||||
<button class="btn btn-sm btn-outline-success mb-1 mr-1 action-unpause" data-id="${containerId}">
|
||||
<i class="fas fa-play"></i> 恢复
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// 更新容器按钮(总是显示)
|
||||
actionButtons += `
|
||||
<button class="btn btn-sm btn-outline-primary mb-1 mr-1 action-update" data-id="${containerId}" data-image="${containerImage || ''}">
|
||||
<i class="fas fa-cloud-download-alt"></i> 更新
|
||||
</button>
|
||||
`;
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td data-label="ID" title="${containerId}">${containerId.substring(0, 12)}</td>
|
||||
<td data-label="名称" title="${containerName}">${containerName}</td>
|
||||
<td data-label="镜像" title="${containerImage}">${containerImage}</td>
|
||||
<td data-label="状态"><span class="badge ${statusClass}">${status}</span></td>
|
||||
<td data-label="操作" class="action-cell">
|
||||
<div class="action-buttons">
|
||||
${actionButtons}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tbody.innerHTML = html;
|
||||
|
||||
// 为所有操作按钮绑定事件
|
||||
this.setupButtonListeners();
|
||||
},
|
||||
|
||||
// 为所有操作按钮绑定事件
|
||||
setupButtonListeners() {
|
||||
// 查找所有操作按钮并绑定点击事件
|
||||
document.querySelectorAll('.action-cell button').forEach(button => {
|
||||
const action = Array.from(button.classList).find(cls => cls.startsWith('action-'));
|
||||
if (!action) return;
|
||||
|
||||
const containerId = button.dataset.id;
|
||||
if (!containerId) return;
|
||||
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const containerName = button.dataset.name;
|
||||
const containerImage = button.dataset.image;
|
||||
|
||||
switch (action) {
|
||||
case 'action-logs':
|
||||
this.showContainerLogs(containerId, containerName);
|
||||
break;
|
||||
case 'action-details':
|
||||
this.showContainerDetails(containerId);
|
||||
break;
|
||||
case 'action-stop':
|
||||
this.stopContainer(containerId);
|
||||
break;
|
||||
case 'action-start':
|
||||
this.startContainer(containerId);
|
||||
break;
|
||||
case 'action-restart':
|
||||
this.restartContainer(containerId);
|
||||
break;
|
||||
case 'action-remove':
|
||||
this.removeContainer(containerId);
|
||||
break;
|
||||
case 'action-unpause':
|
||||
// this.unpauseContainer(containerId); // 假设有这个函数
|
||||
console.warn('Unpause action not implemented yet.');
|
||||
break;
|
||||
case 'action-update':
|
||||
this.updateContainer(containerId, containerImage);
|
||||
break;
|
||||
default:
|
||||
console.warn('Unknown action:', action);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 获取容器状态对应的 CSS 类 - 保持
|
||||
getContainerStatusClass(state) {
|
||||
if (!state) return 'status-unknown';
|
||||
state = state.toLowerCase();
|
||||
if (state.includes('running')) return 'status-running';
|
||||
if (state.includes('created')) return 'status-created';
|
||||
if (state.includes('exited') || state.includes('stopped')) return 'status-stopped';
|
||||
if (state.includes('paused')) return 'status-paused';
|
||||
return 'status-unknown';
|
||||
},
|
||||
|
||||
// 设置下拉菜单动作的事件监听 (委托方法 - 现在直接使用按钮,不再需要)
|
||||
setupActionDropdownListener() {
|
||||
// 这个方法留作兼容性,但实际上我们现在直接使用按钮而非下拉菜单
|
||||
},
|
||||
|
||||
// 查看日志 (示例:用 SweetAlert 显示)
|
||||
async showContainerLogs(containerId, containerName) {
|
||||
core.showLoading('正在加载日志...');
|
||||
try {
|
||||
// 注意: 后端 /api/docker/containers/:id/logs 需要存在并返回日志文本
|
||||
const response = await fetch(`/api/docker/containers/${containerId}/logs`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ details: '无法解析错误响应' }));
|
||||
throw new Error(errorData.details || `获取日志失败 (${response.status})`);
|
||||
}
|
||||
const logs = await response.text();
|
||||
core.hideLoading();
|
||||
|
||||
Swal.fire({
|
||||
title: `容器日志: ${containerName || containerId.substring(0, 6)}`,
|
||||
html: `<pre class="container-logs">${logs.replace(/</g, "<").replace(/>/g, ">")}</pre>`,
|
||||
width: '80%',
|
||||
customClass: {
|
||||
htmlContainer: 'swal2-logs-container',
|
||||
popup: 'swal2-logs-popup'
|
||||
},
|
||||
confirmButtonText: '关闭'
|
||||
});
|
||||
} catch (error) {
|
||||
core.hideLoading();
|
||||
core.showAlert(`查看日志失败: ${error.message}`, 'error');
|
||||
logger.error(`[dockerManager] Error fetching logs for ${containerId}:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
// 显示容器详情 (示例:用 SweetAlert 显示)
|
||||
async showContainerDetails(containerId) {
|
||||
core.showLoading('正在加载详情...');
|
||||
try {
|
||||
// 注意: 后端 /api/docker/containers/:id 需要存在并返回详细信息
|
||||
const response = await fetch(`/api/docker/containers/${containerId}`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ details: '无法解析错误响应' }));
|
||||
throw new Error(errorData.details || `获取详情失败 (${response.status})`);
|
||||
}
|
||||
const details = await response.json();
|
||||
core.hideLoading();
|
||||
|
||||
// 格式化显示详情
|
||||
let detailsHtml = '<div class="container-details">';
|
||||
for (const key in details) {
|
||||
detailsHtml += `<p><strong>${key}:</strong> ${JSON.stringify(details[key], null, 2)}</p>`;
|
||||
}
|
||||
detailsHtml += '</div>';
|
||||
|
||||
Swal.fire({
|
||||
title: `容器详情: ${details.Name || containerId.substring(0, 6)}`,
|
||||
html: detailsHtml,
|
||||
width: '80%',
|
||||
confirmButtonText: '关闭'
|
||||
});
|
||||
} catch (error) {
|
||||
core.hideLoading();
|
||||
core.showAlert(`查看详情失败: ${error.message}`, 'error');
|
||||
logger.error(`[dockerManager] Error fetching details for ${containerId}:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
// 启动容器
|
||||
async startContainer(containerId) {
|
||||
core.showLoading('正在启动容器...');
|
||||
try {
|
||||
const response = await fetch(`/api/docker/containers/${containerId}/start`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
core.hideLoading();
|
||||
if (!response.ok) throw new Error(data.details || '启动容器失败');
|
||||
core.showAlert('容器启动成功', 'success');
|
||||
systemStatus.refreshSystemStatus(); // 刷新整体状态
|
||||
} catch (error) {
|
||||
core.hideLoading();
|
||||
core.showAlert(`启动容器失败: ${error.message}`, 'error');
|
||||
logger.error(`[dockerManager] Error starting container ${containerId}:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
// 停止容器
|
||||
async stopContainer(containerId) {
|
||||
core.showLoading('正在停止容器...');
|
||||
try {
|
||||
const response = await fetch(`/api/docker/containers/${containerId}/stop`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
core.hideLoading();
|
||||
if (!response.ok && response.status !== 304) { // 304 Not Modified 也算成功(已停止)
|
||||
throw new Error(data.details || '停止容器失败');
|
||||
}
|
||||
core.showAlert(data.message || '容器停止成功', 'success');
|
||||
systemStatus.refreshSystemStatus(); // 刷新整体状态
|
||||
} catch (error) {
|
||||
core.hideLoading();
|
||||
core.showAlert(`停止容器失败: ${error.message}`, 'error');
|
||||
logger.error(`[dockerManager] Error stopping container ${containerId}:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
// 重启容器
|
||||
async restartContainer(containerId) {
|
||||
core.showLoading('正在重启容器...');
|
||||
try {
|
||||
const response = await fetch(`/api/docker/containers/${containerId}/restart`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
core.hideLoading();
|
||||
if (!response.ok) throw new Error(data.details || '重启容器失败');
|
||||
core.showAlert('容器重启成功', 'success');
|
||||
systemStatus.refreshSystemStatus(); // 刷新整体状态
|
||||
} catch (error) {
|
||||
core.hideLoading();
|
||||
core.showAlert(`重启容器失败: ${error.message}`, 'error');
|
||||
logger.error(`[dockerManager] Error restarting container ${containerId}:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
// 删除容器 (带确认)
|
||||
removeContainer(containerId) {
|
||||
Swal.fire({
|
||||
title: '确认删除?',
|
||||
text: `确定要删除容器 ${containerId.substring(0, 6)} 吗?此操作不可恢复!`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: 'var(--danger-color)',
|
||||
cancelButtonColor: '#6c757d',
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消'
|
||||
}).then(async (result) => {
|
||||
if (result.isConfirmed) {
|
||||
core.showLoading('正在删除容器...');
|
||||
try {
|
||||
const response = await fetch(`/api/docker/containers/${containerId}/remove`, { method: 'POST' }); // 使用 remove
|
||||
const data = await response.json();
|
||||
core.hideLoading();
|
||||
if (!response.ok) throw new Error(data.details || '删除容器失败');
|
||||
core.showAlert(data.message || '容器删除成功', 'success');
|
||||
systemStatus.refreshSystemStatus(); // 刷新整体状态
|
||||
} catch (error) {
|
||||
core.hideLoading();
|
||||
core.showAlert(`删除容器失败: ${error.message}`, 'error');
|
||||
logger.error(`[dockerManager] Error removing container ${containerId}:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// --- 新增:更新容器函数 ---
|
||||
async updateContainer(containerId, currentImage) {
|
||||
const imageName = currentImage.split(':')[0]; // 提取基础镜像名
|
||||
|
||||
const { value: newTag } = await Swal.fire({
|
||||
title: `更新容器: ${imageName}`,
|
||||
input: 'text',
|
||||
inputLabel: '请输入新的镜像标签 (例如 latest, v1.2)',
|
||||
inputValue: 'latest', // 默认值
|
||||
showCancelButton: true,
|
||||
confirmButtonText: '开始更新',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonColor: '#3085d6',
|
||||
cancelButtonColor: '#d33',
|
||||
inputValidator: (value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return '镜像标签不能为空!';
|
||||
}
|
||||
},
|
||||
// 美化弹窗样式
|
||||
customClass: {
|
||||
container: 'update-container',
|
||||
popup: 'update-popup',
|
||||
header: 'update-header',
|
||||
title: 'update-title',
|
||||
closeButton: 'update-close',
|
||||
icon: 'update-icon',
|
||||
image: 'update-image',
|
||||
content: 'update-content',
|
||||
input: 'update-input',
|
||||
actions: 'update-actions',
|
||||
confirmButton: 'update-confirm',
|
||||
cancelButton: 'update-cancel',
|
||||
footer: 'update-footer'
|
||||
}
|
||||
});
|
||||
|
||||
if (newTag) {
|
||||
// 显示进度弹窗
|
||||
Swal.fire({
|
||||
title: '更新容器',
|
||||
html: `
|
||||
<div class="update-progress">
|
||||
<p>正在更新容器 <strong>${containerId.substring(0, 8)}</strong></p>
|
||||
<p>镜像: <strong>${imageName}:${newTag.trim()}</strong></p>
|
||||
<div class="progress-status">准备中...</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
showConfirmButton: false,
|
||||
allowOutsideClick: false,
|
||||
allowEscapeKey: false,
|
||||
didOpen: () => {
|
||||
const progressBar = Swal.getPopup().querySelector('.progress-bar');
|
||||
const progressStatus = Swal.getPopup().querySelector('.progress-status');
|
||||
|
||||
// 设置初始进度
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.style.backgroundColor = '#4CAF50';
|
||||
|
||||
// 模拟进度动画
|
||||
let progress = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
// 进度最多到95%,剩下的在请求完成后处理
|
||||
if (progress < 95) {
|
||||
progress += Math.random() * 3;
|
||||
if (progress > 95) progress = 95;
|
||||
progressBar.style.width = `${progress}%`;
|
||||
|
||||
// 更新状态文本
|
||||
if (progress < 30) {
|
||||
progressStatus.textContent = "拉取新镜像...";
|
||||
} else if (progress < 60) {
|
||||
progressStatus.textContent = "准备更新容器...";
|
||||
} else if (progress < 90) {
|
||||
progressStatus.textContent = "应用新配置...";
|
||||
} else {
|
||||
progressStatus.textContent = "即将完成...";
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// 发送更新请求
|
||||
this.performContainerUpdate(containerId, newTag.trim(), progressBar, progressStatus, progressInterval);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 执行容器更新请求
|
||||
async performContainerUpdate(containerId, newTag, progressBar, progressStatus, progressInterval) {
|
||||
try {
|
||||
const response = await fetch(`/api/docker/containers/${containerId}/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ tag: newTag })
|
||||
});
|
||||
|
||||
// 清除进度定时器
|
||||
clearInterval(progressInterval);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// 设置进度为100%
|
||||
progressBar.style.width = '100%';
|
||||
progressStatus.textContent = "更新完成!";
|
||||
|
||||
// 显示成功消息
|
||||
setTimeout(() => {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: '更新成功!',
|
||||
text: data.message || '容器已成功更新',
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
|
||||
// 刷新容器列表
|
||||
systemStatus.refreshSystemStatus();
|
||||
}, 800);
|
||||
} else {
|
||||
const data = await response.json().catch(() => ({ error: '解析响应失败', details: '服务器返回了无效的数据' }));
|
||||
|
||||
// 设置进度条为错误状态
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.style.backgroundColor = '#f44336';
|
||||
progressStatus.textContent = "更新失败";
|
||||
|
||||
// 显示错误消息
|
||||
setTimeout(() => {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '更新失败',
|
||||
text: data.details || data.error || '未知错误',
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
}, 800);
|
||||
}
|
||||
} catch (error) {
|
||||
// 清除进度定时器
|
||||
clearInterval(progressInterval);
|
||||
|
||||
// 设置进度条为错误状态
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.style.backgroundColor = '#f44336';
|
||||
progressStatus.textContent = "更新出错";
|
||||
|
||||
// 显示错误信息
|
||||
setTimeout(() => {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '更新失败',
|
||||
text: error.message || '网络请求失败',
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
}, 800);
|
||||
|
||||
// 记录错误日志
|
||||
logger.error(`[dockerManager] Error updating container ${containerId} to tag ${newTag}:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
// --- 新增:绑定排查按钮事件 ---
|
||||
bindTroubleshootButton() {
|
||||
// 使用 setTimeout 确保按钮已经渲染到 DOM 中
|
||||
setTimeout(() => {
|
||||
const troubleshootBtn = document.getElementById('docker-troubleshoot-btn');
|
||||
if (troubleshootBtn) {
|
||||
// 先移除旧监听器,防止重复绑定
|
||||
troubleshootBtn.replaceWith(troubleshootBtn.cloneNode(true));
|
||||
const newBtn = document.getElementById('docker-troubleshoot-btn'); // 重新获取克隆后的按钮
|
||||
if(newBtn) {
|
||||
newBtn.addEventListener('click', () => {
|
||||
if (window.systemStatus && typeof window.systemStatus.showDockerHelp === 'function') {
|
||||
window.systemStatus.showDockerHelp();
|
||||
} else {
|
||||
console.error('[dockerManager] systemStatus.showDockerHelp is not available.');
|
||||
// 可以提供一个备用提示
|
||||
alert('无法显示帮助信息,请检查控制台。');
|
||||
}
|
||||
});
|
||||
console.log('[dockerManager] Troubleshoot button event listener bound.');
|
||||
} else {
|
||||
console.warn('[dockerManager] Cloned troubleshoot button not found after replace.');
|
||||
}
|
||||
} else {
|
||||
console.warn('[dockerManager] Troubleshoot button not found for binding.');
|
||||
}
|
||||
}, 0); // 延迟 0ms 执行,让浏览器有机会渲染
|
||||
}
|
||||
};
|
||||
|
||||
// 确保在 DOM 加载后初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 注意:init 现在只设置监听器,不加载数据
|
||||
// dockerManager.init();
|
||||
// 可以在 app.js 或 systemStatus.js 初始化完成后调用
|
||||
});
|
||||
958
hubcmdui/web/js/documentManager.js
Normal file
958
hubcmdui/web/js/documentManager.js
Normal file
@@ -0,0 +1,958 @@
|
||||
/**
|
||||
* 文档管理模块
|
||||
*/
|
||||
|
||||
// 文档列表
|
||||
let documents = [];
|
||||
// 当前正在编辑的文档
|
||||
let currentDocument = null;
|
||||
// Markdown编辑器实例
|
||||
let editorMd = null;
|
||||
|
||||
// 创建documentManager对象
|
||||
const documentManager = {
|
||||
// 初始化文档管理
|
||||
init: function() {
|
||||
console.log('初始化文档管理模块...');
|
||||
// 渲染表头
|
||||
this.renderDocumentTableHeader();
|
||||
// 加载文档列表
|
||||
return this.loadDocuments().catch(err => {
|
||||
console.error('加载文档列表失败:', err);
|
||||
return Promise.resolve(); // 即使失败也继续初始化过程
|
||||
});
|
||||
},
|
||||
|
||||
// 渲染文档表格头部
|
||||
renderDocumentTableHeader: function() {
|
||||
try {
|
||||
const documentTable = document.getElementById('documentTable');
|
||||
if (!documentTable) {
|
||||
console.warn('文档管理表格元素 (id=\"documentTable\") 未找到,无法渲染表头。');
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找或创建 thead
|
||||
let thead = documentTable.querySelector('thead');
|
||||
if (!thead) {
|
||||
thead = document.createElement('thead');
|
||||
documentTable.insertBefore(thead, documentTable.firstChild); // 确保 thead 在 tbody 之前
|
||||
console.log('创建了文档表格的 thead 元素。');
|
||||
}
|
||||
|
||||
// 设置表头内容 (包含 ID 列)
|
||||
thead.innerHTML = `
|
||||
<tr>
|
||||
<th style="width: 5%">#</th>
|
||||
<th style="width: 25%">标题</th>
|
||||
<th style="width: 20%">创建时间</th>
|
||||
<th style="width: 20%">更新时间</th>
|
||||
<th style="width: 10%">状态</th>
|
||||
<th style="width: 20%">操作</th>
|
||||
</tr>
|
||||
`;
|
||||
console.log('文档表格表头已渲染。');
|
||||
} catch (error) {
|
||||
console.error('渲染文档表格表头时出错:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 加载文档列表
|
||||
loadDocuments: async function() {
|
||||
try {
|
||||
// 显示加载状态
|
||||
const documentTableBody = document.getElementById('documentTableBody');
|
||||
if (documentTableBody) {
|
||||
documentTableBody.innerHTML = '<tr><td colspan="6" style="text-align: center;"><i class="fas fa-spinner fa-spin"></i> 正在加载文档列表...</td></tr>';
|
||||
}
|
||||
|
||||
// 简化会话检查逻辑,只验证会话是否有效
|
||||
let sessionValid = true;
|
||||
try {
|
||||
const sessionResponse = await fetch('/api/check-session', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (sessionResponse.status === 401) {
|
||||
console.warn('会话已过期,无法加载文档');
|
||||
sessionValid = false;
|
||||
}
|
||||
} catch (sessionError) {
|
||||
console.warn('检查会话状态发生网络错误:', sessionError);
|
||||
// 发生网络错误时继续尝试加载文档
|
||||
}
|
||||
|
||||
// 尝试不同的API路径
|
||||
const possiblePaths = [
|
||||
'/api/documents',
|
||||
'/api/documentation-list',
|
||||
'/api/documentation'
|
||||
];
|
||||
|
||||
let success = false;
|
||||
let authError = false;
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
try {
|
||||
console.log(`尝试从 ${path} 获取文档列表`);
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
console.warn(`API路径 ${path} 返回未授权状态`);
|
||||
authError = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
documents = Array.isArray(data) ? data : [];
|
||||
|
||||
// 确保每个文档都包含必要的时间字段
|
||||
documents = documents.map(doc => {
|
||||
if (!doc.createdAt && doc.updatedAt) {
|
||||
// 如果没有创建时间但有更新时间,使用更新时间
|
||||
doc.createdAt = doc.updatedAt;
|
||||
} else if (!doc.createdAt) {
|
||||
// 如果都没有,使用当前时间
|
||||
doc.createdAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
if (!doc.updatedAt) {
|
||||
// 如果没有更新时间,使用创建时间
|
||||
doc.updatedAt = doc.createdAt;
|
||||
}
|
||||
|
||||
return doc;
|
||||
});
|
||||
|
||||
// 先渲染表头,再渲染文档列表
|
||||
this.renderDocumentTableHeader();
|
||||
this.renderDocumentList();
|
||||
console.log(`成功从API路径 ${path} 加载文档列表`, documents);
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`从 ${path} 加载文档失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理认证错误 - 只有当会话检查和API请求都明确失败时才强制登出
|
||||
if ((authError || !sessionValid) && !success && localStorage.getItem('isLoggedIn') === 'true') {
|
||||
console.warn('会话检查和API请求均指示会话已过期');
|
||||
core.showAlert('会话已过期,请重新登录', 'warning');
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果API请求失败但不是认证错误,显示空文档列表
|
||||
if (!success) {
|
||||
console.log('API请求失败,显示空文档列表');
|
||||
documents = [];
|
||||
// 仍然需要渲染表头
|
||||
this.renderDocumentTableHeader();
|
||||
this.renderDocumentList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文档失败:', error);
|
||||
// 在UI上显示错误
|
||||
const documentTableBody = document.getElementById('documentTableBody');
|
||||
if (documentTableBody) {
|
||||
documentTableBody.innerHTML = `<tr><td colspan="6" style="text-align: center; color: red;">加载文档失败: ${error.message} <button onclick="documentManager.loadDocuments()">重试</button></td></tr>`;
|
||||
}
|
||||
|
||||
// 设置为空文档列表
|
||||
documents = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 初始化编辑器
|
||||
initEditor: function() {
|
||||
try {
|
||||
const editorContainer = document.getElementById('editor');
|
||||
if (!editorContainer) {
|
||||
console.error('找不到编辑器容器元素');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 toastui 是否已加载
|
||||
console.log('检查编辑器依赖项:', typeof toastui);
|
||||
|
||||
// 确保 toastui 对象存在
|
||||
if (typeof toastui === 'undefined') {
|
||||
console.error('Toast UI Editor 未加载');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建编辑器实例
|
||||
editorMd = new toastui.Editor({
|
||||
el: editorContainer,
|
||||
height: '600px',
|
||||
initialValue: '',
|
||||
previewStyle: 'vertical',
|
||||
initialEditType: 'markdown',
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task', 'indent', 'outdent'],
|
||||
['table', 'image', 'link'],
|
||||
['code', 'codeblock']
|
||||
]
|
||||
});
|
||||
|
||||
console.log('编辑器初始化完成', editorMd);
|
||||
} catch (error) {
|
||||
console.error('初始化编辑器出错:', error);
|
||||
core.showAlert('初始化编辑器失败: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 检查编辑器是否已初始化
|
||||
isEditorInitialized: function() {
|
||||
return editorMd !== null;
|
||||
},
|
||||
|
||||
// 创建新文档
|
||||
newDocument: function() {
|
||||
// 首先确保编辑器已初始化
|
||||
if (!editorMd) {
|
||||
this.initEditor();
|
||||
// 等待编辑器初始化完成后再继续
|
||||
setTimeout(() => {
|
||||
currentDocument = null;
|
||||
document.getElementById('documentTitle').value = '';
|
||||
editorMd.setMarkdown('');
|
||||
this.showEditor();
|
||||
}, 500);
|
||||
} else {
|
||||
currentDocument = null;
|
||||
document.getElementById('documentTitle').value = '';
|
||||
editorMd.setMarkdown('');
|
||||
this.showEditor();
|
||||
}
|
||||
},
|
||||
|
||||
// 显示编辑器
|
||||
showEditor: function() {
|
||||
document.getElementById('documentTable').style.display = 'none';
|
||||
document.getElementById('editorContainer').style.display = 'block';
|
||||
if (editorMd) {
|
||||
// 确保每次显示编辑器时都切换到编辑模式
|
||||
editorMd.focus();
|
||||
}
|
||||
},
|
||||
|
||||
// 隐藏编辑器
|
||||
hideEditor: function() {
|
||||
document.getElementById('documentTable').style.display = 'table';
|
||||
document.getElementById('editorContainer').style.display = 'none';
|
||||
},
|
||||
|
||||
// 取消编辑
|
||||
cancelEdit: function() {
|
||||
this.hideEditor();
|
||||
},
|
||||
|
||||
// 保存文档
|
||||
saveDocument: async function() {
|
||||
const title = document.getElementById('documentTitle').value.trim();
|
||||
const content = editorMd.getMarkdown();
|
||||
|
||||
if (!title) {
|
||||
core.showAlert('请输入文档标题', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示保存中状态
|
||||
core.showLoading();
|
||||
|
||||
try {
|
||||
// 简化会话检查逻辑,只验证会话是否有效
|
||||
let sessionValid = true;
|
||||
try {
|
||||
const sessionResponse = await fetch('/api/check-session', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (sessionResponse.status === 401) {
|
||||
console.warn('会话已过期,无法保存文档');
|
||||
sessionValid = false;
|
||||
}
|
||||
} catch (sessionError) {
|
||||
console.warn('检查会话状态发生网络错误:', sessionError);
|
||||
// 发生网络错误时继续尝试保存操作
|
||||
}
|
||||
|
||||
// 只有在会话明确无效时才退出
|
||||
if (!sessionValid) {
|
||||
core.showAlert('您的会话已过期,请重新登录', 'warning');
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保Markdown内容以标题开始
|
||||
let processedContent = content;
|
||||
if (!content.startsWith('# ')) {
|
||||
// 如果内容不是以一级标题开始,则在开头添加标题
|
||||
processedContent = `# ${title}\n\n${content}`;
|
||||
} else {
|
||||
// 如果已经有一级标题,替换为当前标题
|
||||
processedContent = content.replace(/^# .*$/m, `# ${title}`);
|
||||
}
|
||||
|
||||
const apiUrl = currentDocument && currentDocument.id
|
||||
? `/api/documents/${currentDocument.id}`
|
||||
: '/api/documents';
|
||||
|
||||
const method = currentDocument && currentDocument.id ? 'PUT' : 'POST';
|
||||
|
||||
console.log(`尝试${method === 'PUT' ? '更新' : '创建'}文档,标题: ${title}`);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content: processedContent,
|
||||
published: currentDocument && currentDocument.published ? currentDocument.published : false
|
||||
})
|
||||
});
|
||||
|
||||
// 处理响应
|
||||
if (response.status === 401) {
|
||||
// 明确的未授权响应
|
||||
console.warn('保存文档返回401未授权');
|
||||
core.showAlert('未登录或会话已过期,请重新登录', 'warning');
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorData;
|
||||
try {
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch (e) {
|
||||
// 如果不是有效的JSON,直接使用文本
|
||||
throw new Error(errorText || '保存失败,请重试');
|
||||
}
|
||||
throw new Error(errorData.error || errorData.message || '保存失败,请重试');
|
||||
}
|
||||
|
||||
const savedDoc = await response.json();
|
||||
console.log('保存的文档:', savedDoc);
|
||||
|
||||
// 确保savedDoc包含必要的时间字段
|
||||
if (savedDoc) {
|
||||
// 如果返回的保存文档中没有时间字段,从API获取完整文档信息
|
||||
if (!savedDoc.createdAt || !savedDoc.updatedAt) {
|
||||
try {
|
||||
const docId = savedDoc.id || (currentDocument ? currentDocument.id : null);
|
||||
if (docId) {
|
||||
const docResponse = await fetch(`/api/documents/${docId}`, {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (docResponse.ok) {
|
||||
const fullDoc = await docResponse.json();
|
||||
Object.assign(savedDoc, {
|
||||
createdAt: fullDoc.createdAt,
|
||||
updatedAt: fullDoc.updatedAt
|
||||
});
|
||||
console.log('获取到完整的文档时间信息:', fullDoc);
|
||||
}
|
||||
}
|
||||
} catch (timeError) {
|
||||
console.warn('获取文档完整时间信息失败:', timeError);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新文档列表中的文档
|
||||
const existingIndex = documents.findIndex(d => d.id === savedDoc.id);
|
||||
if (existingIndex >= 0) {
|
||||
documents[existingIndex] = { ...documents[existingIndex], ...savedDoc };
|
||||
} else {
|
||||
documents.push(savedDoc);
|
||||
}
|
||||
}
|
||||
|
||||
core.showAlert('文档保存成功', 'success');
|
||||
this.hideEditor();
|
||||
await this.loadDocuments(); // 重新加载文档列表
|
||||
} catch (error) {
|
||||
console.error('保存文档失败:', error);
|
||||
core.showAlert('保存文档失败: ' + error.message, 'error');
|
||||
} finally {
|
||||
core.hideLoading();
|
||||
}
|
||||
},
|
||||
|
||||
// 渲染文档列表
|
||||
renderDocumentList: function() {
|
||||
const tbody = document.getElementById('documentTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (documents.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center;">没有找到文档</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
documents.forEach((doc, index) => {
|
||||
// 确保文档时间有合理默认值
|
||||
let createdAt = '未知';
|
||||
let updatedAt = '未知';
|
||||
|
||||
try {
|
||||
// 尝试解析创建时间,如果失败则回退到默认值
|
||||
if (doc.createdAt) {
|
||||
const createdDate = new Date(doc.createdAt);
|
||||
if (!isNaN(createdDate.getTime())) {
|
||||
createdAt = createdDate.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试解析更新时间,如果失败则回退到默认值
|
||||
if (doc.updatedAt) {
|
||||
const updatedDate = new Date(doc.updatedAt);
|
||||
if (!isNaN(updatedDate.getTime())) {
|
||||
updatedAt = updatedDate.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 简化时间显示逻辑 - 直接显示时间戳,不添加特殊标记
|
||||
if (createdAt === '未知' && updatedAt !== '未知') {
|
||||
// 如果没有创建时间但有更新时间
|
||||
createdAt = '未记录';
|
||||
} else if (updatedAt === '未知' && createdAt !== '未知') {
|
||||
// 如果没有更新时间但有创建时间
|
||||
updatedAt = '未更新';
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`解析文档时间失败:`, error, doc);
|
||||
}
|
||||
|
||||
const statusClasses = doc.published ? 'status-badge status-running' : 'status-badge status-stopped';
|
||||
const statusText = doc.published ? '已发布' : '未发布';
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td>${doc.title || '无标题文档'}</td>
|
||||
<td>${createdAt}</td>
|
||||
<td>${updatedAt}</td>
|
||||
<td><span class="${statusClasses}">${statusText}</span></td>
|
||||
<td class="action-buttons">
|
||||
<button class="action-btn edit-btn" title="编辑文档" onclick="documentManager.editDocument('${doc.id}')">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="action-btn ${doc.published ? 'unpublish-btn' : 'publish-btn'}"
|
||||
title="${doc.published ? '取消发布' : '发布文档'}"
|
||||
onclick="documentManager.togglePublish('${doc.id}')">
|
||||
<i class="fas ${doc.published ? 'fa-toggle-off' : 'fa-toggle-on'}"></i>
|
||||
</button>
|
||||
<button class="action-btn delete-btn" title="删除文档" onclick="documentManager.deleteDocument('${doc.id}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
// 编辑文档
|
||||
editDocument: async function(id) {
|
||||
try {
|
||||
console.log(`准备编辑文档,ID: ${id}`);
|
||||
core.showLoading();
|
||||
|
||||
// 检查会话状态,优化会话检查逻辑
|
||||
let sessionValid = true;
|
||||
try {
|
||||
const sessionResponse = await fetch('/api/check-session', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (sessionResponse.status === 401) {
|
||||
console.warn('会话已过期,无法编辑文档');
|
||||
sessionValid = false;
|
||||
}
|
||||
} catch (sessionError) {
|
||||
console.warn('检查会话状态发生网络错误:', sessionError);
|
||||
// 发生网络错误时不立即判定会话失效,继续尝试编辑操作
|
||||
}
|
||||
|
||||
// 只有在明确会话无效时才提示重新登录
|
||||
if (!sessionValid) {
|
||||
core.showAlert('您的会话已过期,请重新登录', 'warning');
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// 在本地查找文档
|
||||
currentDocument = documents.find(doc => doc.id === id);
|
||||
|
||||
// 如果本地未找到,从API获取
|
||||
if (!currentDocument && id) {
|
||||
try {
|
||||
console.log('从API获取文档详情');
|
||||
|
||||
// 尝试多个可能的API路径
|
||||
const apiPaths = [
|
||||
`/api/documents/${id}`,
|
||||
`/api/documentation/${id}`
|
||||
];
|
||||
|
||||
let docResponse = null;
|
||||
let authError = false;
|
||||
|
||||
for (const apiPath of apiPaths) {
|
||||
try {
|
||||
console.log(`尝试从 ${apiPath} 获取文档`);
|
||||
const response = await fetch(apiPath, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
// 只有明确401错误才认定为会话过期
|
||||
if (response.status === 401) {
|
||||
console.warn(`API ${apiPath} 返回401未授权`);
|
||||
authError = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
docResponse = response;
|
||||
console.log(`成功从 ${apiPath} 获取文档`);
|
||||
break;
|
||||
}
|
||||
} catch (pathError) {
|
||||
console.warn(`从 ${apiPath} 获取文档失败:`, pathError);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理认证错误
|
||||
if (authError && !docResponse) {
|
||||
core.showAlert('未登录或会话已过期,请重新登录', 'warning');
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (docResponse && docResponse.ok) {
|
||||
currentDocument = await docResponse.json();
|
||||
console.log('获取到文档详情:', currentDocument);
|
||||
|
||||
// 确保文档包含必要的时间字段
|
||||
if (!currentDocument.createdAt && currentDocument.updatedAt) {
|
||||
// 如果没有创建时间但有更新时间,使用更新时间
|
||||
currentDocument.createdAt = currentDocument.updatedAt;
|
||||
} else if (!currentDocument.createdAt) {
|
||||
// 如果都没有,使用当前时间
|
||||
currentDocument.createdAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
if (!currentDocument.updatedAt) {
|
||||
// 如果没有更新时间,使用创建时间
|
||||
currentDocument.updatedAt = currentDocument.createdAt;
|
||||
}
|
||||
|
||||
// 将获取到的文档添加到文档列表中
|
||||
const existingIndex = documents.findIndex(d => d.id === id);
|
||||
if (existingIndex >= 0) {
|
||||
documents[existingIndex] = currentDocument;
|
||||
} else {
|
||||
documents.push(currentDocument);
|
||||
}
|
||||
} else {
|
||||
throw new Error('所有API路径都无法获取文档');
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error('从API获取文档详情失败:', apiError);
|
||||
core.showAlert('获取文档详情失败: ' + apiError.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然没有找到文档,显示错误
|
||||
if (!currentDocument) {
|
||||
core.showAlert('未找到指定的文档', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示编辑器界面并设置内容
|
||||
this.showEditor();
|
||||
|
||||
// 确保编辑器已初始化
|
||||
if (!editorMd) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
this.initEditor();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// 设置文档内容
|
||||
if (editorMd) {
|
||||
document.getElementById('documentTitle').value = currentDocument.title || '';
|
||||
|
||||
if (currentDocument.content) {
|
||||
console.log(`设置文档内容,长度: ${currentDocument.content.length}`);
|
||||
editorMd.setMarkdown(currentDocument.content);
|
||||
} else {
|
||||
console.log('文档内容为空,尝试额外获取内容');
|
||||
|
||||
// 如果文档内容为空,尝试额外获取内容
|
||||
try {
|
||||
const contentResponse = await fetch(`/api/documents/${id}/content`, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
// 只有明确401错误才提示重新登录
|
||||
if (contentResponse.status === 401) {
|
||||
core.showAlert('会话已过期,请重新登录', 'warning');
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentResponse.ok) {
|
||||
const contentData = await contentResponse.json();
|
||||
if (contentData.content) {
|
||||
currentDocument.content = contentData.content;
|
||||
editorMd.setMarkdown(contentData.content);
|
||||
console.log('成功获取额外内容');
|
||||
}
|
||||
}
|
||||
} catch (contentError) {
|
||||
console.warn('获取额外内容失败:', contentError);
|
||||
}
|
||||
|
||||
// 如果仍然没有内容,设置为空
|
||||
if (!currentDocument.content) {
|
||||
editorMd.setMarkdown('');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('编辑器初始化失败,无法设置内容');
|
||||
core.showAlert('编辑器初始化失败,请刷新页面重试', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('编辑文档时出错:', error);
|
||||
core.showAlert('编辑文档失败: ' + error.message, 'error');
|
||||
} finally {
|
||||
core.hideLoading();
|
||||
}
|
||||
},
|
||||
|
||||
// 查看文档
|
||||
viewDocument: function(id) {
|
||||
const doc = documents.find(doc => doc.id === id);
|
||||
if (!doc) {
|
||||
core.showAlert('未找到指定的文档', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
title: doc.title,
|
||||
html: `<div class="document-preview">${marked.parse(doc.content || '')}</div>`,
|
||||
width: '70%',
|
||||
showCloseButton: true,
|
||||
showConfirmButton: false,
|
||||
customClass: {
|
||||
container: 'document-preview-container',
|
||||
popup: 'document-preview-popup',
|
||||
content: 'document-preview-content'
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 删除文档
|
||||
deleteDocument: async function(id) {
|
||||
Swal.fire({
|
||||
title: '确定要删除此文档吗?',
|
||||
text: "此操作无法撤销!",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#d33',
|
||||
cancelButtonColor: '#3085d6',
|
||||
confirmButtonText: '是,删除它',
|
||||
cancelButtonText: '取消'
|
||||
}).then(async (result) => {
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
console.log(`尝试删除文档: ${id}`);
|
||||
|
||||
// 检查会话状态
|
||||
const sessionResponse = await fetch('/api/check-session', {
|
||||
headers: { 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
|
||||
if (sessionResponse.status === 401) {
|
||||
// 会话已过期,提示用户并重定向到登录
|
||||
core.showAlert('您的会话已过期,请重新登录', 'warning');
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用正确的API路径删除
|
||||
const response = await fetch(`/api/documents/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin' // 确保发送cookie
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// 处理未授权错误
|
||||
core.showAlert('未登录或会话已过期,请重新登录', 'warning');
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '删除文档失败');
|
||||
}
|
||||
|
||||
console.log('文档删除成功响应:', await response.json());
|
||||
core.showAlert('文档已成功删除', 'success');
|
||||
await this.loadDocuments(); // 重新加载文档列表
|
||||
} catch (error) {
|
||||
console.error('删除文档失败:', error);
|
||||
core.showAlert('删除文档失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 切换文档发布状态
|
||||
togglePublish: async function(id) {
|
||||
try {
|
||||
const doc = documents.find(d => d.id === id);
|
||||
if (!doc) {
|
||||
throw new Error('找不到指定文档');
|
||||
}
|
||||
|
||||
// 添加加载指示
|
||||
core.showLoading();
|
||||
|
||||
// 检查会话状态
|
||||
const sessionResponse = await fetch('/api/check-session', {
|
||||
headers: { 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
|
||||
if (sessionResponse.status === 401) {
|
||||
// 会话已过期,提示用户并重定向到登录
|
||||
core.showAlert('您的会话已过期,请重新登录', 'warning');
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`尝试切换文档 ${id} 的发布状态,当前状态:`, doc.published);
|
||||
|
||||
// 构建更新请求数据
|
||||
const updateData = {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
published: !doc.published // 切换发布状态
|
||||
};
|
||||
|
||||
// 使用正确的API端点进行更新
|
||||
const response = await fetch(`/api/documentation/toggle-publish/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin', // 确保发送cookie
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// 处理未授权错误
|
||||
core.showAlert('未登录或会话已过期,请重新登录', 'warning');
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '更新文档状态失败');
|
||||
}
|
||||
|
||||
// 更新成功
|
||||
const updatedDoc = await response.json();
|
||||
console.log('文档状态更新响应:', updatedDoc);
|
||||
|
||||
// 更新本地文档列表
|
||||
const docIndex = documents.findIndex(d => d.id === id);
|
||||
if (docIndex >= 0) {
|
||||
documents[docIndex].published = updatedDoc.published;
|
||||
this.renderDocumentList();
|
||||
}
|
||||
|
||||
core.showAlert('文档状态已更新', 'success');
|
||||
} catch (error) {
|
||||
console.error('更改发布状态失败:', error);
|
||||
core.showAlert('更改发布状态失败: ' + error.message, 'error');
|
||||
} finally {
|
||||
core.hideLoading();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 全局公开文档管理模块
|
||||
window.documentManager = documentManager;
|
||||
|
||||
/**
|
||||
* 显示指定文档的内容
|
||||
* @param {string} docId 文档ID
|
||||
*/
|
||||
async function showDocument(docId) {
|
||||
try {
|
||||
console.log('正在获取文档内容,ID:', docId);
|
||||
|
||||
// 显示加载状态
|
||||
const documentContent = document.getElementById('documentContent');
|
||||
if (documentContent) {
|
||||
documentContent.innerHTML = '<div class="loading-container"><i class="fas fa-spinner fa-spin"></i> 正在加载文档内容...</div>';
|
||||
}
|
||||
|
||||
// 获取文档内容
|
||||
const response = await fetch(`/api/documentation/${docId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取文档内容失败,状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const doc = await response.json();
|
||||
console.log('获取到文档:', doc);
|
||||
|
||||
// 更新文档内容区域
|
||||
if (documentContent) {
|
||||
if (doc.content) {
|
||||
// 使用marked渲染markdown内容
|
||||
documentContent.innerHTML = `
|
||||
<h1>${doc.title || '无标题'}</h1>
|
||||
${doc.lastUpdated ? `<div class="doc-meta">最后更新: ${new Date(doc.lastUpdated).toLocaleDateString('zh-CN')}</div>` : ''}
|
||||
<div class="doc-content">${window.marked ? marked.parse(doc.content) : doc.content}</div>
|
||||
`;
|
||||
} else {
|
||||
documentContent.innerHTML = `
|
||||
<h1>${doc.title || '无标题'}</h1>
|
||||
<div class="empty-content">
|
||||
<i class="fas fa-file-alt fa-3x"></i>
|
||||
<p>该文档暂无内容</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
console.error('找不到文档内容容器,ID: documentContent');
|
||||
}
|
||||
|
||||
// 高亮当前选中的文档
|
||||
highlightSelectedDocument(docId);
|
||||
} catch (error) {
|
||||
console.error('获取文档内容失败:', error);
|
||||
|
||||
// 显示错误信息
|
||||
const documentContent = document.getElementById('documentContent');
|
||||
if (documentContent) {
|
||||
documentContent.innerHTML = `
|
||||
<div class="error-container">
|
||||
<i class="fas fa-exclamation-triangle fa-3x"></i>
|
||||
<h2>加载失败</h2>
|
||||
<p>无法获取文档内容: ${error.message}</p>
|
||||
<button class="btn btn-retry" onclick="showDocument('${docId}')">重试</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮选中的文档
|
||||
* @param {string} docId 文档ID
|
||||
*/
|
||||
function highlightSelectedDocument(docId) {
|
||||
// 移除所有高亮
|
||||
const docLinks = document.querySelectorAll('.doc-list .doc-item');
|
||||
docLinks.forEach(link => link.classList.remove('active'));
|
||||
|
||||
// 添加当前高亮
|
||||
const selectedLink = document.querySelector(`.doc-list .doc-item[data-id="${docId}"]`);
|
||||
if (selectedLink) {
|
||||
selectedLink.classList.add('active');
|
||||
// 确保选中项可见
|
||||
selectedLink.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
85
hubcmdui/web/js/error-handler.js
Normal file
85
hubcmdui/web/js/error-handler.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// 客户端错误收集器
|
||||
|
||||
(function() {
|
||||
// 保存原始控制台方法
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
// 重写console.error以捕获错误
|
||||
console.error = function(...args) {
|
||||
// 调用原始方法
|
||||
originalConsoleError.apply(console, args);
|
||||
|
||||
// 提取错误信息
|
||||
const errorMessage = args.map(arg => {
|
||||
if (arg instanceof Error) {
|
||||
return arg.stack || arg.message;
|
||||
} else if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (e) {
|
||||
return String(arg);
|
||||
}
|
||||
} else {
|
||||
return String(arg);
|
||||
}
|
||||
}).join(' ');
|
||||
|
||||
// 向服务器报告错误
|
||||
reportErrorToServer({
|
||||
message: errorMessage,
|
||||
source: 'console.error',
|
||||
type: 'console'
|
||||
});
|
||||
};
|
||||
|
||||
// 全局错误处理
|
||||
window.addEventListener('error', function(event) {
|
||||
reportErrorToServer({
|
||||
message: event.message,
|
||||
source: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
stack: event.error ? event.error.stack : null,
|
||||
type: 'uncaught'
|
||||
});
|
||||
});
|
||||
|
||||
// Promise错误处理
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
const message = event.reason instanceof Error
|
||||
? event.reason.message
|
||||
: String(event.reason);
|
||||
|
||||
const stack = event.reason instanceof Error
|
||||
? event.reason.stack
|
||||
: null;
|
||||
|
||||
reportErrorToServer({
|
||||
message: message,
|
||||
stack: stack,
|
||||
type: 'promise'
|
||||
});
|
||||
});
|
||||
|
||||
// 向服务器发送错误报告
|
||||
function reportErrorToServer(errorData) {
|
||||
// 添加额外信息
|
||||
const data = {
|
||||
...errorData,
|
||||
userAgent: navigator.userAgent,
|
||||
page: window.location.href,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 发送错误报告到服务器
|
||||
fetch('/api/client-error', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
// 使用keepalive以确保在页面卸载时仍能发送
|
||||
keepalive: true
|
||||
}).catch(err => {
|
||||
// 不记录这个错误,避免无限循环
|
||||
});
|
||||
}
|
||||
})();
|
||||
573
hubcmdui/web/js/menuManager.js
Normal file
573
hubcmdui/web/js/menuManager.js
Normal file
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* 菜单管理模块 - 管理 data/config.json 中的 menuItems
|
||||
*/
|
||||
|
||||
// 菜单项列表 (从 config.json 读取)
|
||||
let configMenuItems = [];
|
||||
let currentConfig = {}; // 保存当前完整的配置
|
||||
|
||||
// 创建menuManager对象
|
||||
const menuManager = {
|
||||
// 初始化菜单管理
|
||||
init: async function() {
|
||||
console.log('初始化菜单管理 (config.json)...');
|
||||
this.renderMenuTableHeader(); // 渲染表头
|
||||
await this.loadMenuItems(); // 加载菜单项
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
// 渲染菜单表格头部 (根据 config.json 结构调整)
|
||||
renderMenuTableHeader: function() {
|
||||
const menuTable = document.getElementById('menuTable');
|
||||
if (!menuTable) return;
|
||||
|
||||
const thead = menuTable.querySelector('thead') || document.createElement('thead');
|
||||
thead.innerHTML = `
|
||||
<tr>
|
||||
<th style="width: 5%">#</th>
|
||||
<th style="width: 25%">文本 (Text)</th>
|
||||
<th style="width: 40%">链接 (Link)</th>
|
||||
<th style="width: 10%">新标签页 (New Tab)</th>
|
||||
<th style="width: 20%">操作</th>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
if (!menuTable.querySelector('thead')) {
|
||||
menuTable.appendChild(thead);
|
||||
}
|
||||
},
|
||||
|
||||
// 加载菜单项 (从 /api/config 获取)
|
||||
loadMenuItems: async function() {
|
||||
try {
|
||||
const menuTableBody = document.getElementById('menuTableBody');
|
||||
if (menuTableBody) {
|
||||
menuTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center;"><i class="fas fa-spinner fa-spin"></i> 正在加载菜单项...</td></tr>';
|
||||
}
|
||||
|
||||
const response = await fetch('/api/config'); // 请求配置接口
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取配置失败: ${response.statusText || response.status}`);
|
||||
}
|
||||
|
||||
currentConfig = await response.json(); // 保存完整配置
|
||||
configMenuItems = currentConfig.menuItems || []; // 提取菜单项,如果不存在则为空数组
|
||||
|
||||
this.renderMenuItems();
|
||||
console.log('成功从 /api/config 加载菜单项', configMenuItems);
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载菜单项失败:', error);
|
||||
const menuTableBody = document.getElementById('menuTableBody');
|
||||
if (menuTableBody) {
|
||||
menuTableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: #ff4d4f;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
加载菜单项失败: ${error.message}
|
||||
<button onclick="menuManager.loadMenuItems()" class="retry-btn">
|
||||
<i class="fas fa-sync"></i> 重试
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 渲染菜单项 (根据 config.json 结构)
|
||||
renderMenuItems: function() {
|
||||
const menuTableBody = document.getElementById('menuTableBody');
|
||||
if (!menuTableBody) return;
|
||||
|
||||
menuTableBody.innerHTML = ''; // 清空现有内容
|
||||
|
||||
if (!Array.isArray(configMenuItems)) {
|
||||
console.error("configMenuItems 不是一个数组:", configMenuItems);
|
||||
menuTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: #ff4d4f;">菜单数据格式错误</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
configMenuItems.forEach((item, index) => {
|
||||
const row = document.createElement('tr');
|
||||
// 使用 index 作为临时 ID 进行操作
|
||||
row.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td>${item.text || ''}</td>
|
||||
<td>${item.link || ''}</td>
|
||||
<td>${item.newTab ? '是' : '否'}</td>
|
||||
<td class="action-buttons">
|
||||
<button class="action-btn edit-btn" title="编辑菜单" onclick="menuManager.editMenuItem(${index})">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="action-btn delete-btn" title="删除菜单" onclick="menuManager.deleteMenuItem(${index})">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
menuTableBody.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
// 显示新菜单项行 (调整字段)
|
||||
showNewMenuItemRow: function() {
|
||||
const menuTableBody = document.getElementById('menuTableBody');
|
||||
if (!menuTableBody) return;
|
||||
|
||||
const newRow = document.createElement('tr');
|
||||
newRow.id = 'new-menu-item-row';
|
||||
newRow.className = 'new-item-row';
|
||||
|
||||
newRow.innerHTML = `
|
||||
<td>#</td>
|
||||
<td><input type="text" id="new-text" placeholder="菜单文本"></td>
|
||||
<td><input type="text" id="new-link" placeholder="链接地址"></td>
|
||||
<td>
|
||||
<select id="new-newTab">
|
||||
<option value="false">否</option>
|
||||
<option value="true">是</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button class="action-btn" onclick="menuManager.saveNewMenuItem()">保存</button>
|
||||
<button class="action-btn" onclick="menuManager.cancelNewMenuItem()">取消</button>
|
||||
</td>
|
||||
`;
|
||||
menuTableBody.appendChild(newRow);
|
||||
document.getElementById('new-text').focus();
|
||||
},
|
||||
|
||||
// 保存新菜单项 (更新整个配置)
|
||||
saveNewMenuItem: async function() {
|
||||
try {
|
||||
const text = document.getElementById('new-text').value.trim();
|
||||
const link = document.getElementById('new-link').value.trim();
|
||||
const newTab = document.getElementById('new-newTab').value === 'true';
|
||||
|
||||
if (!text || !link) {
|
||||
throw new Error('文本和链接为必填项');
|
||||
}
|
||||
|
||||
const newMenuItem = { text, link, newTab };
|
||||
|
||||
// 创建更新后的配置对象
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
menuItems: [...(currentConfig.menuItems || []), newMenuItem] // 添加新项
|
||||
};
|
||||
|
||||
// 调用API保存整个配置
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedConfig) // 发送更新后的完整配置
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`保存配置失败: ${errorData.details || response.statusText}`);
|
||||
}
|
||||
|
||||
// 重新加载菜单项以更新视图和 currentConfig
|
||||
await this.loadMenuItems();
|
||||
this.cancelNewMenuItem(); // 移除编辑行
|
||||
core.showAlert('菜单项已添加', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('添加菜单项失败:', error);
|
||||
core.showAlert('添加菜单项失败: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 取消新菜单项
|
||||
cancelNewMenuItem: function() {
|
||||
const newRow = document.getElementById('new-menu-item-row');
|
||||
if (newRow) {
|
||||
newRow.remove();
|
||||
}
|
||||
},
|
||||
|
||||
// 编辑菜单项 (使用 index 定位)
|
||||
editMenuItem: function(index) {
|
||||
const item = configMenuItems[index];
|
||||
if (!item) {
|
||||
core.showAlert('找不到指定的菜单项', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
title: '<div class="edit-title"><i class="fas fa-edit"></i> 编辑菜单项</div>',
|
||||
html: `
|
||||
<div class="edit-menu-form">
|
||||
<div class="form-group">
|
||||
<label for="edit-text">
|
||||
<i class="fas fa-font"></i> 菜单文本
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="text" id="edit-text" class="modern-input" value="${item.text || ''}" placeholder="请输入菜单文本">
|
||||
<span class="input-icon"><i class="fas fa-heading"></i></span>
|
||||
</div>
|
||||
<small class="form-hint">菜单项显示的文本,保持简洁明了</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-link">
|
||||
<i class="fas fa-link"></i> 链接地址
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="text" id="edit-link" class="modern-input" value="${item.link || ''}" placeholder="请输入链接地址">
|
||||
<span class="input-icon"><i class="fas fa-globe"></i></span>
|
||||
</div>
|
||||
<small class="form-hint">完整URL路径(例如: https://example.com)或相对路径(例如: /docs)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group toggle-switch">
|
||||
<label for="edit-newTab" class="toggle-label-text">
|
||||
<i class="fas fa-external-link-alt"></i> 在新标签页打开
|
||||
</label>
|
||||
<div class="toggle-switch-container">
|
||||
<input type="checkbox" id="edit-newTab" class="toggle-input" ${item.newTab ? 'checked' : ''}>
|
||||
<label for="edit-newTab" class="toggle-label"></label>
|
||||
<span class="toggle-status">${item.newTab ? '是' : '否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-preview">
|
||||
<div class="preview-title"><i class="fas fa-eye"></i> 预览</div>
|
||||
<div class="preview-content">
|
||||
<a href="${item.link || '#'}" class="preview-link" target="${item.newTab ? '_blank' : '_self'}">
|
||||
<span class="preview-text">${item.text || '菜单项'}</span>
|
||||
${item.newTab ? '<i class="fas fa-external-link-alt preview-icon"></i>' : ''}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.edit-title {
|
||||
font-size: 1.5rem;
|
||||
color: #3085d6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.edit-menu-form {
|
||||
text-align: left;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.modern-input {
|
||||
width: 100%;
|
||||
padding: 12px 40px 12px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
||||
}
|
||||
.modern-input:focus {
|
||||
border-color: #3085d6;
|
||||
box-shadow: 0 0 0 3px rgba(48, 133, 214, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #aaa;
|
||||
}
|
||||
.form-hint {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
.toggle-switch {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
}
|
||||
.toggle-label-text {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.toggle-switch-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.toggle-input {
|
||||
display: none;
|
||||
}
|
||||
.toggle-label {
|
||||
display: block;
|
||||
width: 52px;
|
||||
height: 26px;
|
||||
background: #e6e6e6;
|
||||
border-radius: 13px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.toggle-label:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.toggle-input:checked + .toggle-label {
|
||||
background: #3085d6;
|
||||
}
|
||||
.toggle-input:checked + .toggle-label:after {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
.toggle-status {
|
||||
margin-left: 10px;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
min-width: 20px;
|
||||
}
|
||||
.form-preview {
|
||||
margin-top: 25px;
|
||||
border: 1px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.preview-title {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.preview-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
.preview-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #3085d6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.preview-link:hover {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
.preview-text {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.preview-icon {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
`,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: '<i class="fas fa-save"></i> 保存',
|
||||
cancelButtonText: '<i class="fas fa-times"></i> 取消',
|
||||
confirmButtonColor: '#3085d6',
|
||||
cancelButtonColor: '#6c757d',
|
||||
width: '550px',
|
||||
focusConfirm: false,
|
||||
customClass: {
|
||||
container: 'menu-edit-container',
|
||||
popup: 'menu-edit-popup',
|
||||
title: 'menu-edit-title',
|
||||
confirmButton: 'menu-edit-confirm',
|
||||
cancelButton: 'menu-edit-cancel'
|
||||
},
|
||||
didOpen: () => {
|
||||
// 添加输入监听,更新预览
|
||||
const textInput = document.getElementById('edit-text');
|
||||
const linkInput = document.getElementById('edit-link');
|
||||
const newTabToggle = document.getElementById('edit-newTab');
|
||||
const toggleStatus = document.querySelector('.toggle-status');
|
||||
const previewText = document.querySelector('.preview-text');
|
||||
const previewLink = document.querySelector('.preview-link');
|
||||
const previewIcon = document.querySelector('.preview-icon') || document.createElement('i');
|
||||
|
||||
if (!previewIcon.classList.contains('fas')) {
|
||||
previewIcon.className = 'fas fa-external-link-alt preview-icon';
|
||||
}
|
||||
|
||||
const updatePreview = () => {
|
||||
previewText.textContent = textInput.value || '菜单项';
|
||||
previewLink.href = linkInput.value || '#';
|
||||
previewLink.target = newTabToggle.checked ? '_blank' : '_self';
|
||||
|
||||
if (newTabToggle.checked) {
|
||||
if (!previewLink.contains(previewIcon)) {
|
||||
previewLink.appendChild(previewIcon);
|
||||
}
|
||||
} else {
|
||||
if (previewLink.contains(previewIcon)) {
|
||||
previewLink.removeChild(previewIcon);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
textInput.addEventListener('input', updatePreview);
|
||||
linkInput.addEventListener('input', updatePreview);
|
||||
newTabToggle.addEventListener('change', () => {
|
||||
toggleStatus.textContent = newTabToggle.checked ? '是' : '否';
|
||||
updatePreview();
|
||||
});
|
||||
},
|
||||
preConfirm: () => {
|
||||
const text = document.getElementById('edit-text').value.trim();
|
||||
const link = document.getElementById('edit-link').value.trim();
|
||||
const newTab = document.getElementById('edit-newTab').checked;
|
||||
|
||||
if (!text) {
|
||||
Swal.showValidationMessage('<i class="fas fa-exclamation-circle"></i> 菜单文本不能为空');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!link) {
|
||||
Swal.showValidationMessage('<i class="fas fa-exclamation-circle"></i> 链接地址不能为空');
|
||||
return false;
|
||||
}
|
||||
|
||||
return { text, link, newTab };
|
||||
}
|
||||
}).then(async (result) => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
try {
|
||||
// 显示保存中状态
|
||||
Swal.fire({
|
||||
title: '保存中...',
|
||||
html: '<i class="fas fa-spinner fa-spin"></i> 正在保存菜单项',
|
||||
showConfirmButton: false,
|
||||
allowOutsideClick: false,
|
||||
willOpen: () => {
|
||||
Swal.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
// 更新配置中的菜单项
|
||||
configMenuItems[index] = result.value;
|
||||
|
||||
// 创建更新后的配置对象
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
menuItems: configMenuItems // 更新后的菜单项数组
|
||||
};
|
||||
|
||||
// 调用API保存整个配置
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedConfig) // 发送更新后的完整配置
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`保存配置失败: ${errorData.details || response.statusText}`);
|
||||
}
|
||||
|
||||
// 重新渲染菜单项
|
||||
this.renderMenuItems();
|
||||
|
||||
// 显示成功消息
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: '保存成功',
|
||||
html: '<i class="fas fa-check-circle"></i> 菜单项已更新',
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('更新菜单项失败:', error);
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '保存失败',
|
||||
html: `<i class="fas fa-times-circle"></i> 更新菜单项失败: ${error.message}`,
|
||||
confirmButtonText: '确定'
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 删除菜单项
|
||||
deleteMenuItem: function(index) {
|
||||
const item = configMenuItems[index];
|
||||
if (!item) {
|
||||
core.showAlert('找不到指定的菜单项', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
title: '确认删除',
|
||||
text: `确定要删除菜单项 "${item.text}" 吗?`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonColor: '#d33'
|
||||
}).then(async (result) => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
try {
|
||||
// 从菜单项数组中移除指定项
|
||||
configMenuItems.splice(index, 1);
|
||||
|
||||
// 创建更新后的配置对象
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
menuItems: configMenuItems // 更新后的菜单项数组
|
||||
};
|
||||
|
||||
// 调用API保存整个配置
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedConfig) // 发送更新后的完整配置
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`保存配置失败: ${errorData.details || response.statusText}`);
|
||||
}
|
||||
|
||||
// 重新渲染菜单项
|
||||
this.renderMenuItems();
|
||||
core.showAlert('菜单项已删除', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('删除菜单项失败:', error);
|
||||
core.showAlert('删除菜单项失败: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 全局公开菜单管理模块
|
||||
window.menuManager = menuManager;
|
||||
93
hubcmdui/web/js/networkTest.js
Normal file
93
hubcmdui/web/js/networkTest.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// 网络测试相关功能
|
||||
|
||||
// 创建networkTest对象
|
||||
const networkTest = {
|
||||
// 初始化函数
|
||||
init: function() {
|
||||
console.log('初始化网络测试模块...');
|
||||
this.initNetworkTest();
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
// 初始化网络测试界面
|
||||
initNetworkTest: function() {
|
||||
const domainSelect = document.getElementById('domainSelect');
|
||||
const testType = document.getElementById('testType');
|
||||
|
||||
// 填充域名选择器
|
||||
if (domainSelect) {
|
||||
domainSelect.innerHTML = `
|
||||
<option value="">选择预定义域名</option>
|
||||
<option value="gcr.io">gcr.io</option>
|
||||
<option value="ghcr.io">ghcr.io</option>
|
||||
<option value="quay.io">quay.io</option>
|
||||
<option value="k8s.gcr.io">k8s.gcr.io</option>
|
||||
<option value="registry.k8s.io">registry.k8s.io</option>
|
||||
<option value="mcr.microsoft.com">mcr.microsoft.com</option>
|
||||
<option value="docker.elastic.co">docker.elastic.co</option>
|
||||
<option value="registry-1.docker.io">registry-1.docker.io</option>
|
||||
`;
|
||||
}
|
||||
|
||||
// 填充测试类型选择器
|
||||
if (testType) {
|
||||
testType.innerHTML = `
|
||||
<option value="ping">Ping</option>
|
||||
<option value="traceroute">Traceroute</option>
|
||||
`;
|
||||
}
|
||||
|
||||
// 绑定测试按钮点击事件
|
||||
const testButton = document.querySelector('#network-test button');
|
||||
if (testButton) {
|
||||
testButton.addEventListener('click', this.runNetworkTest);
|
||||
}
|
||||
},
|
||||
|
||||
// 运行网络测试
|
||||
runNetworkTest: function() {
|
||||
const domain = document.getElementById('domainSelect').value;
|
||||
const testType = document.getElementById('testType').value;
|
||||
const resultsDiv = document.getElementById('testResults');
|
||||
|
||||
// 验证选择了域名
|
||||
if (!domain) {
|
||||
core.showAlert('请选择目标域名', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = '测试中,请稍候...';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60秒超时
|
||||
|
||||
fetch('/api/network-test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ domain, type: testType }),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(response => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!response.ok) {
|
||||
throw new Error('网络测试失败');
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(result => {
|
||||
resultsDiv.textContent = result;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('网络测试出错:', error);
|
||||
if (error.name === 'AbortError') {
|
||||
resultsDiv.textContent = '测试超时,请稍后再试';
|
||||
} else {
|
||||
resultsDiv.textContent = '测试失败: ' + error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 全局公开网络测试模块
|
||||
window.networkTest = networkTest;
|
||||
1121
hubcmdui/web/js/systemStatus.js
Normal file
1121
hubcmdui/web/js/systemStatus.js
Normal file
File diff suppressed because it is too large
Load Diff
260
hubcmdui/web/js/userCenter.js
Normal file
260
hubcmdui/web/js/userCenter.js
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 用户中心管理模块
|
||||
*/
|
||||
|
||||
// 获取用户信息
|
||||
async function getUserInfo() {
|
||||
try {
|
||||
// 先检查是否已登录
|
||||
const sessionResponse = await fetch('/api/check-session');
|
||||
const sessionData = await sessionResponse.json();
|
||||
|
||||
if (!sessionData.authenticated) {
|
||||
// 用户未登录,不显示错误,静默返回
|
||||
console.log('用户未登录或会话无效,跳过获取用户信息');
|
||||
return;
|
||||
}
|
||||
|
||||
// 用户已登录,获取用户信息
|
||||
console.log('会话有效,尝试获取用户信息...');
|
||||
const response = await fetch('/api/user-info');
|
||||
if (!response.ok) {
|
||||
// 检查是否是认证问题
|
||||
if (response.status === 401) {
|
||||
console.log('会话已过期,需要重新登录');
|
||||
return;
|
||||
}
|
||||
throw new Error('获取用户信息失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('获取到用户信息:', data);
|
||||
|
||||
// 更新顶部导航栏的用户名
|
||||
const currentUsername = document.getElementById('currentUsername');
|
||||
if (currentUsername) {
|
||||
currentUsername.textContent = data.username || '未知用户';
|
||||
}
|
||||
|
||||
// 更新统计卡片数据
|
||||
const loginCountElement = document.getElementById('loginCount');
|
||||
if (loginCountElement) {
|
||||
loginCountElement.textContent = data.loginCount || '0';
|
||||
}
|
||||
|
||||
const accountAgeElement = document.getElementById('accountAge');
|
||||
if (accountAgeElement) {
|
||||
accountAgeElement.textContent = data.accountAge ? `${data.accountAge}天` : '0天';
|
||||
}
|
||||
|
||||
const lastLoginElement = document.getElementById('lastLogin');
|
||||
if (lastLoginElement) {
|
||||
let lastLogin = data.lastLogin || '未知';
|
||||
// 检查是否包含 "今天" 字样,添加样式
|
||||
if (lastLogin.includes('今天')) {
|
||||
lastLoginElement.innerHTML = `<span class="today-login">${lastLogin}</span>`;
|
||||
} else {
|
||||
lastLoginElement.textContent = lastLogin;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
// 不显示错误通知,只在控制台记录错误
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
async function changePassword(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const form = document.getElementById('changePasswordForm');
|
||||
const currentPassword = form.querySelector('#ucCurrentPassword').value;
|
||||
const newPassword = form.querySelector('#ucNewPassword').value;
|
||||
const confirmPassword = form.querySelector('#ucConfirmPassword').value;
|
||||
|
||||
// 验证表单
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
return core.showAlert('所有字段都不能为空', 'error');
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return core.showAlert('两次输入的新密码不一致', 'error');
|
||||
}
|
||||
|
||||
// 密码复杂度检查
|
||||
if (!isPasswordComplex(newPassword)) {
|
||||
return core.showAlert('密码必须包含至少1个字母、1个数字和1个特殊字符,长度在8-16位之间', 'error');
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalButtonText = submitButton.innerHTML;
|
||||
submitButton.disabled = true;
|
||||
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提交中...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
// 无论成功与否,去除加载状态
|
||||
submitButton.disabled = false;
|
||||
submitButton.innerHTML = originalButtonText;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '修改密码失败');
|
||||
}
|
||||
|
||||
// 清空表单
|
||||
form.reset();
|
||||
|
||||
// 设置倒计时并显示
|
||||
let countDown = 5;
|
||||
|
||||
Swal.fire({
|
||||
title: '密码修改成功',
|
||||
html: `您的密码已成功修改,系统将在 <b>${countDown}</b> 秒后自动退出,请使用新密码重新登录。`,
|
||||
icon: 'success',
|
||||
timer: countDown * 1000,
|
||||
timerProgressBar: true,
|
||||
didOpen: () => {
|
||||
const content = Swal.getHtmlContainer();
|
||||
const timerInterval = setInterval(() => {
|
||||
countDown--;
|
||||
if (content) {
|
||||
const b = content.querySelector('b');
|
||||
if (b) {
|
||||
b.textContent = countDown > 0 ? countDown : 0;
|
||||
}
|
||||
}
|
||||
if (countDown <= 0) clearInterval(timerInterval);
|
||||
}, 1000);
|
||||
},
|
||||
allowOutsideClick: false, // 禁止外部点击关闭
|
||||
showConfirmButton: true, // 重新显示确认按钮
|
||||
confirmButtonText: '确定' // 设置按钮文本
|
||||
}).then((result) => {
|
||||
// 当计时器结束或弹窗被关闭时 (包括点击确定按钮)
|
||||
if (result.dismiss === Swal.DismissReason.timer || result.isConfirmed) {
|
||||
console.log('计时器结束或手动确认,执行登出');
|
||||
auth.logout();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error);
|
||||
core.showAlert('修改密码失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 验证密码复杂度
|
||||
function isPasswordComplex(password) {
|
||||
// 至少包含1个字母、1个数字和1个特殊字符,长度在8-16位之间
|
||||
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&])[A-Za-z\d.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&]{8,16}$/;
|
||||
return passwordRegex.test(password);
|
||||
}
|
||||
|
||||
// 检查密码强度
|
||||
function checkUcPasswordStrength() {
|
||||
const password = document.getElementById('ucNewPassword').value;
|
||||
const strengthSpan = document.getElementById('ucPasswordStrength');
|
||||
|
||||
if (!password) {
|
||||
strengthSpan.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let strength = 0;
|
||||
let strengthText = '';
|
||||
|
||||
// 长度检查
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.length >= 12) strength++;
|
||||
|
||||
// 包含字母
|
||||
if (/[A-Za-z]/.test(password)) strength++;
|
||||
|
||||
// 包含数字
|
||||
if (/\d/.test(password)) strength++;
|
||||
|
||||
// 包含特殊字符
|
||||
if (/[.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&]/.test(password)) strength++;
|
||||
|
||||
// 根据强度设置文本和颜色
|
||||
switch(strength) {
|
||||
case 0:
|
||||
case 1:
|
||||
strengthText = '密码强度:非常弱';
|
||||
strengthSpan.style.color = '#FF4136';
|
||||
break;
|
||||
case 2:
|
||||
strengthText = '密码强度:弱';
|
||||
strengthSpan.style.color = '#FF851B';
|
||||
break;
|
||||
case 3:
|
||||
strengthText = '密码强度:中';
|
||||
strengthSpan.style.color = '#FFDC00';
|
||||
break;
|
||||
case 4:
|
||||
strengthText = '密码强度:强';
|
||||
strengthSpan.style.color = '#2ECC40';
|
||||
break;
|
||||
case 5:
|
||||
strengthText = '密码强度:非常强';
|
||||
strengthSpan.style.color = '#3D9970';
|
||||
break;
|
||||
}
|
||||
|
||||
strengthSpan.textContent = strengthText;
|
||||
}
|
||||
|
||||
// 初始化用户中心
|
||||
function initUserCenter() {
|
||||
console.log('初始化用户中心');
|
||||
|
||||
// 获取用户信息
|
||||
getUserInfo();
|
||||
|
||||
// 为用户中心按钮添加事件
|
||||
const userCenterBtn = document.getElementById('userCenterBtn');
|
||||
if (userCenterBtn) {
|
||||
userCenterBtn.addEventListener('click', () => {
|
||||
core.showSection('user-center');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户统计信息
|
||||
function loadUserStats() {
|
||||
getUserInfo();
|
||||
}
|
||||
|
||||
// 导出模块
|
||||
const userCenter = {
|
||||
init: function() {
|
||||
console.log('初始化用户中心模块...');
|
||||
// 可以在这里调用初始化逻辑,也可以延迟到需要时调用
|
||||
return Promise.resolve(); // 返回一个已解决的 Promise,保持与其他模块一致
|
||||
},
|
||||
getUserInfo,
|
||||
changePassword,
|
||||
checkUcPasswordStrength,
|
||||
initUserCenter,
|
||||
loadUserStats,
|
||||
isPasswordComplex
|
||||
};
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', initUserCenter);
|
||||
|
||||
// 全局公开用户中心模块
|
||||
window.userCenter = userCenter;
|
||||
224
hubcmdui/web/services/documentationService.js
Normal file
224
hubcmdui/web/services/documentationService.js
Normal file
@@ -0,0 +1,224 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
// 文档存储目录
|
||||
const DOCUMENTATION_DIR = path.join(__dirname, '..', 'data', 'documentation');
|
||||
|
||||
/**
|
||||
* 确保文档目录存在
|
||||
*/
|
||||
async function ensureDocumentationDirExists() {
|
||||
if (!fs.existsSync(DOCUMENTATION_DIR)) {
|
||||
await fs.promises.mkdir(DOCUMENTATION_DIR, { recursive: true });
|
||||
console.log(`创建文档目录: ${DOCUMENTATION_DIR}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档列表
|
||||
* @returns {Promise<Array>} 文档列表
|
||||
*/
|
||||
async function getDocumentList() {
|
||||
try {
|
||||
await ensureDocumentationDirExists();
|
||||
|
||||
// 检查索引文件是否存在
|
||||
const indexPath = path.join(DOCUMENTATION_DIR, 'index.json');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
// 创建空索引,不再添加默认文档
|
||||
await fs.promises.writeFile(indexPath, JSON.stringify([]), 'utf8');
|
||||
console.log('创建了空的文档索引文件');
|
||||
return [];
|
||||
}
|
||||
|
||||
// 读取索引文件
|
||||
const data = await fs.promises.readFile(indexPath, 'utf8');
|
||||
return JSON.parse(data || '[]');
|
||||
} catch (error) {
|
||||
console.error('获取文档列表失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文档列表
|
||||
* @param {Array} docList 文档列表
|
||||
*/
|
||||
async function saveDocumentList(docList) {
|
||||
try {
|
||||
await ensureDocumentationDirExists();
|
||||
|
||||
const indexPath = path.join(DOCUMENTATION_DIR, 'index.json');
|
||||
await fs.promises.writeFile(indexPath, JSON.stringify(docList, null, 2), 'utf8');
|
||||
console.log('文档列表已更新');
|
||||
} catch (error) {
|
||||
console.error('保存文档列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个文档的内容
|
||||
* @param {string} docId 文档ID
|
||||
* @returns {Promise<string>} 文档内容
|
||||
*/
|
||||
async function getDocumentContent(docId) {
|
||||
try {
|
||||
console.log(`尝试获取文档内容,ID: ${docId}`);
|
||||
|
||||
// 确保文档目录存在
|
||||
await ensureDocumentationDirExists();
|
||||
|
||||
// 获取文档列表
|
||||
const docList = await getDocumentList();
|
||||
const doc = docList.find(doc => doc.id === docId || doc._id === docId);
|
||||
|
||||
if (!doc) {
|
||||
throw new Error(`文档不存在,ID: ${docId}`);
|
||||
}
|
||||
|
||||
// 构建文档路径
|
||||
const docPath = path.join(DOCUMENTATION_DIR, `${docId}.md`);
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(docPath)) {
|
||||
return ''; // 文件不存在,返回空内容
|
||||
}
|
||||
|
||||
// 读取文档内容
|
||||
const content = await fs.promises.readFile(docPath, 'utf8');
|
||||
console.log(`成功读取文档内容,ID: ${docId}, 内容长度: ${content.length}`);
|
||||
|
||||
return content;
|
||||
} catch (error) {
|
||||
console.error(`获取文档内容失败,ID: ${docId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新文档
|
||||
* @param {Object} doc 文档对象
|
||||
* @param {string} content 文档内容
|
||||
* @returns {Promise<Object>} 保存后的文档
|
||||
*/
|
||||
async function saveDocument(doc, content) {
|
||||
try {
|
||||
await ensureDocumentationDirExists();
|
||||
|
||||
// 获取现有文档列表
|
||||
const docList = await getDocumentList();
|
||||
|
||||
// 为新文档生成ID
|
||||
if (!doc.id) {
|
||||
doc.id = uuidv4();
|
||||
}
|
||||
|
||||
// 更新文档元数据
|
||||
doc.lastUpdated = new Date().toISOString();
|
||||
|
||||
// 查找现有文档索引
|
||||
const existingIndex = docList.findIndex(item => item.id === doc.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// 更新现有文档
|
||||
docList[existingIndex] = { ...docList[existingIndex], ...doc };
|
||||
} else {
|
||||
// 添加新文档
|
||||
docList.push(doc);
|
||||
}
|
||||
|
||||
// 保存文档内容
|
||||
const docPath = path.join(DOCUMENTATION_DIR, `${doc.id}.md`);
|
||||
await fs.promises.writeFile(docPath, content || '', 'utf8');
|
||||
|
||||
// 更新文档列表
|
||||
await saveDocumentList(docList);
|
||||
|
||||
return doc;
|
||||
} catch (error) {
|
||||
console.error('保存文档失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档
|
||||
* @param {string} docId 文档ID
|
||||
* @returns {Promise<boolean>} 删除结果
|
||||
*/
|
||||
async function deleteDocument(docId) {
|
||||
try {
|
||||
await ensureDocumentationDirExists();
|
||||
|
||||
// 获取现有文档列表
|
||||
const docList = await getDocumentList();
|
||||
|
||||
// 查找文档索引
|
||||
const existingIndex = docList.findIndex(doc => doc.id === docId);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
return false; // 文档不存在
|
||||
}
|
||||
|
||||
// 从列表中移除
|
||||
docList.splice(existingIndex, 1);
|
||||
|
||||
// 删除文档文件
|
||||
const docPath = path.join(DOCUMENTATION_DIR, `${docId}.md`);
|
||||
if (fs.existsSync(docPath)) {
|
||||
await fs.promises.unlink(docPath);
|
||||
}
|
||||
|
||||
// 更新文档列表
|
||||
await saveDocumentList(docList);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('删除文档失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布或取消发布文档
|
||||
* @param {string} docId 文档ID
|
||||
* @param {boolean} publishState 发布状态
|
||||
* @returns {Promise<Object>} 更新后的文档
|
||||
*/
|
||||
async function togglePublishState(docId, publishState) {
|
||||
try {
|
||||
// 获取现有文档列表
|
||||
const docList = await getDocumentList();
|
||||
|
||||
// 查找文档索引
|
||||
const existingIndex = docList.findIndex(doc => doc.id === docId);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
throw new Error('文档不存在');
|
||||
}
|
||||
|
||||
// 更新发布状态
|
||||
docList[existingIndex].published = !!publishState;
|
||||
docList[existingIndex].lastUpdated = new Date().toISOString();
|
||||
|
||||
// 更新文档列表
|
||||
await saveDocumentList(docList);
|
||||
|
||||
return docList[existingIndex];
|
||||
} catch (error) {
|
||||
console.error('更新文档发布状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureDocumentationDirExists,
|
||||
getDocumentList,
|
||||
saveDocumentList,
|
||||
getDocumentContent,
|
||||
saveDocument,
|
||||
deleteDocument,
|
||||
togglePublishState
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
--primary-color: #3D7CF4;
|
||||
--primary-light: #5D95FD;
|
||||
--primary-dark: #2F62C9;
|
||||
|
||||
--primary-dark-color: #2c5282; /* 深蓝色,可以根据您的主题调整 */
|
||||
/* 辅助色 */
|
||||
--secondary-color: #FF6B6B;
|
||||
--secondary-light: #FF8E8E;
|
||||
@@ -749,34 +749,169 @@
|
||||
}
|
||||
|
||||
#documentationText {
|
||||
padding: 0 1rem;
|
||||
padding: 0.5rem 1.5rem; /* 增加左右内边距 */
|
||||
max-width: 900px; /* 限制最大宽度以提高可读性 */
|
||||
/* margin-left: auto; 移除左边距自动 */
|
||||
/* margin-right: auto; 移除右边距自动 */
|
||||
}
|
||||
|
||||
#documentationText h2 {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.8em;
|
||||
margin-top: 2.5em;
|
||||
margin-bottom: 1em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.4em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#documentationText p {
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.2rem;
|
||||
margin-bottom: 1.5rem; /* 增大段落间距 */
|
||||
font-size: 1.05rem; /* 稍微增大正文字号 */
|
||||
}
|
||||
|
||||
#documentationText ul, #documentationText ol {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
padding-left: 1.5rem;
|
||||
padding-left: 1.8em; /* 调整缩进 */
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
#documentationText li {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
#documentationText pre {
|
||||
margin: 1.5rem 0;
|
||||
background-color: #1F2937; /* 深色背景 */
|
||||
color: #F3F4F6; /* 浅色文字 */
|
||||
padding: 1.2rem 1.5rem; /* 调整内边距 */
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
margin: 1.8rem 0; /* 增加垂直外边距 */
|
||||
line-height: 1.6; /* 调整行高 */
|
||||
border: 1px solid #374151; /* 深色边框 */
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; /* 使用适合编程的字体 */
|
||||
font-size: 0.95rem; /* 标准化字体大小 */
|
||||
position: relative; /* 为复制按钮定位 */
|
||||
}
|
||||
|
||||
.doc-content pre::before {
|
||||
content: ''; /* 模拟终端窗口的顶部栏 */
|
||||
display: block;
|
||||
height: 28px; /* 顶部栏高度 */
|
||||
background-color: #111827; /* 顶部栏颜色 */
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
margin: -1.2rem -1.5rem 1rem -1.5rem; /* 定位和添加下方间距 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 模拟窗口按钮 */
|
||||
.doc-content pre::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 15px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #FF5F57;
|
||||
border-radius: 50%;
|
||||
box-shadow: 20px 0 #FEBC2E, 40px 0 #28C840;
|
||||
}
|
||||
|
||||
.doc-content pre code {
|
||||
display: block; /* 确保代码块充满 pre */
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
white-space: pre; /* 保留空格和换行 */
|
||||
font-size: inherit; /* 继承 pre 的字号 */
|
||||
}
|
||||
|
||||
/* 行内代码样式 */
|
||||
.doc-content code {
|
||||
font-family: 'Fira Code', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
font-size: 0.9em; /* 调整大小 */
|
||||
background-color: rgba(61, 124, 244, 0.1); /* 更柔和的背景 */
|
||||
padding: 0.25em 0.5em;
|
||||
margin: 0 0.1em;
|
||||
border-radius: 4px;
|
||||
color: #2c5282; /* 主色调的深色 */
|
||||
border: 1px solid rgba(61, 124, 244, 0.2);
|
||||
vertical-align: middle; /* 垂直对齐 */
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
.doc-content a {
|
||||
color: var(--primary-dark); /* 使用更深的蓝色 */
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(61, 124, 244, 0.4);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.doc-content a:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration-color: var(--primary-color);
|
||||
background-color: rgba(61, 124, 244, 0.05);
|
||||
}
|
||||
|
||||
/* 引用块样式 */
|
||||
.doc-content blockquote {
|
||||
margin: 2em 0;
|
||||
padding: 1em 1.5em;
|
||||
color: #555;
|
||||
border-left: 4px solid var(--primary-light);
|
||||
background-color: #f8faff; /* 淡蓝色背景 */
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.doc-content blockquote p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.doc-content table {
|
||||
border-collapse: separate; /* 使用 separate 以应用圆角 */
|
||||
border-spacing: 0;
|
||||
margin: 1.8rem 0;
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden; /* 应用圆角 */
|
||||
}
|
||||
|
||||
.doc-content th,
|
||||
.doc-content td {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.8em 1.2em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.doc-content tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.doc-content th {
|
||||
font-weight: 600;
|
||||
background-color: #f7f9fc; /* 更浅的表头背景 */
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.doc-content tr:nth-child(even) td {
|
||||
background-color: #fafcff; /* 斑马纹 */
|
||||
}
|
||||
|
||||
/* 元数据 (更新时间) 样式 */
|
||||
.doc-meta {
|
||||
font-size: 0.9em;
|
||||
color: #888;
|
||||
text-align: right; /* 右对齐 */
|
||||
margin-top: 0rem; /* 调整与内容的距离 */
|
||||
padding-top: 0.5rem; /* 增加顶部内边距 */
|
||||
border-top: 1px dashed #eee; /* 放到顶部 */
|
||||
clear: both; /* 确保在内容下方 */
|
||||
}
|
||||
|
||||
/* 加载中和消息提示 */
|
||||
@@ -2006,3 +2141,637 @@
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 登录页面特定样式 - 确保按钮正确显示 */
|
||||
.login-container .captcha-area {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
gap: 10px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.login-container #captcha {
|
||||
flex: 1.5 !important;
|
||||
min-width: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.login-container #captchaText {
|
||||
flex: 1 !important;
|
||||
min-width: 80px !important;
|
||||
height: 44px !important;
|
||||
text-align: center !important;
|
||||
line-height: 44px !important;
|
||||
background-color: #f5f7fa !important;
|
||||
border: 1px solid #ddd !important;
|
||||
border-radius: 5px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.login-container .btn-login {
|
||||
flex: 1 !important;
|
||||
min-width: 80px !important;
|
||||
height: 44px !important;
|
||||
margin: 0 !important;
|
||||
white-space: nowrap !important;
|
||||
width: auto !important;
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* 处理登录按钮在hover时的行为 */
|
||||
.login-container .btn-login:hover {
|
||||
background-color: var(--primary-dark) !important;
|
||||
transform: none !important; /* 防止按钮hover时的上移效果导致布局变化 */
|
||||
}
|
||||
|
||||
/* 确保登录表单中的其他按钮不受影响 */
|
||||
.login-container button:not(.btn-login) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 修复在移动设备上的显示 */
|
||||
@media (max-width: 480px) {
|
||||
.login-container .captcha-area {
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
|
||||
.login-container #captcha {
|
||||
flex: 100% !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.login-container #captchaText {
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.login-container .btn-login {
|
||||
flex: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录按钮样式 */
|
||||
#loginButton {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
padding: 10px !important;
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
border-radius: 5px !important;
|
||||
cursor: pointer !important;
|
||||
font-size: 16px !important;
|
||||
margin-top: 15px !important;
|
||||
}
|
||||
|
||||
#loginButton:hover {
|
||||
background-color: var(--primary-dark-color) !important;
|
||||
}
|
||||
|
||||
.login-form button {
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.login-form button:hover {
|
||||
background-color: var(--primary-dark-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.login-modal .login-content .login-form #loginButton:hover {
|
||||
background-color: var(--primary-dark-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* ======================
|
||||
文档内容样式 (Doc Content) - 优化版
|
||||
====================== */
|
||||
|
||||
/* 文档文本容器整体调整 */
|
||||
#documentationText {
|
||||
padding: 0.5rem 1.5rem; /* 增加左右内边距 */
|
||||
max-width: 900px; /* 限制最大宽度以提高可读性 */
|
||||
/* margin-left: auto; 移除左边距自动 */
|
||||
/* margin-right: auto; 移除右边距自动 */
|
||||
}
|
||||
|
||||
.doc-content {
|
||||
font-family: 'Georgia', 'Times New Roman', 'Source Han Serif CN', 'Songti SC', serif; /* 使用更适合阅读的衬线字体 */
|
||||
line-height: 1.8; /* 增加行高 */
|
||||
color: #333;
|
||||
padding-top: 0; /* 移除顶部padding,让标题控制间距 */
|
||||
margin-bottom: 2rem; /* 内容和元数据之间的间距 */
|
||||
}
|
||||
|
||||
/* 标题样式调整 */
|
||||
.doc-content h1:first-of-type, /* 优先将第一个h1作为主标题 */
|
||||
.doc-content h2:first-of-type,
|
||||
.doc-content h3:first-of-type {
|
||||
margin-top: 0; /* 移除主标题上方的间距 */
|
||||
margin-bottom: 1rem; /* 主标题下方间距 */
|
||||
padding-bottom: 0.5rem; /* 标题下划线间距 */
|
||||
border-bottom: 2px solid var(--primary-color); /* 强调主标题下划线 */
|
||||
font-size: 2.5em; /* 增大主标题字号 */
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.doc-content h2 {
|
||||
font-size: 1.8em;
|
||||
margin-top: 2.5em;
|
||||
margin-bottom: 1em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.4em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-content h3 {
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.8em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-content h4 {
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.8em;
|
||||
margin-bottom: 0.7em;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
/* 段落样式 */
|
||||
.doc-content p {
|
||||
margin-bottom: 1.5rem; /* 增大段落间距 */
|
||||
font-size: 1.05rem; /* 稍微增大正文字号 */
|
||||
}
|
||||
|
||||
/* 列表样式 */
|
||||
.doc-content ul,
|
||||
.doc-content ol {
|
||||
padding-left: 1.8em; /* 调整缩进 */
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.doc-content li {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
/* 元数据 (更新时间) 样式 */
|
||||
.doc-meta {
|
||||
font-size: 0.9em;
|
||||
color: #888;
|
||||
text-align: right; /* 右对齐 */
|
||||
margin-top: 0rem; /* 调整与内容的距离 */
|
||||
padding-top: 0.5rem; /* 增加顶部内边距 */
|
||||
border-top: 1px dashed #eee; /* 放到顶部 */
|
||||
clear: both; /* 确保在内容下方 */
|
||||
}
|
||||
|
||||
/* 代码块样式统一 */
|
||||
.doc-content pre {
|
||||
background-color: #1F2937; /* 深色背景 */
|
||||
color: #F3F4F6; /* 浅色文字 */
|
||||
padding: 1.2rem 1.5rem; /* 调整内边距 */
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
margin: 1.8rem 0; /* 增加垂直外边距 */
|
||||
line-height: 1.6; /* 调整行高 */
|
||||
border: 1px solid #374151; /* 深色边框 */
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; /* 使用适合编程的字体 */
|
||||
font-size: 0.95rem; /* 标准化字体大小 */
|
||||
position: relative; /* 为复制按钮定位 */
|
||||
}
|
||||
|
||||
.doc-content pre::before {
|
||||
content: ''; /* 模拟终端窗口的顶部栏 */
|
||||
display: block;
|
||||
height: 28px; /* 顶部栏高度 */
|
||||
background-color: #111827; /* 顶部栏颜色 */
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
margin: -1.2rem -1.5rem 1rem -1.5rem; /* 定位和添加下方间距 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 模拟窗口按钮 */
|
||||
.doc-content pre::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 15px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #FF5F57;
|
||||
border-radius: 50%;
|
||||
box-shadow: 20px 0 #FEBC2E, 40px 0 #28C840;
|
||||
}
|
||||
|
||||
.doc-content pre code {
|
||||
display: block; /* 确保代码块充满 pre */
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
white-space: pre; /* 保留空格和换行 */
|
||||
font-size: inherit; /* 继承 pre 的字号 */
|
||||
}
|
||||
|
||||
/* 行内代码样式 */
|
||||
.doc-content code {
|
||||
font-family: 'Fira Code', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
font-size: 0.9em; /* 调整大小 */
|
||||
background-color: rgba(61, 124, 244, 0.1); /* 更柔和的背景 */
|
||||
padding: 0.25em 0.5em;
|
||||
margin: 0 0.1em;
|
||||
border-radius: 4px;
|
||||
color: #2c5282; /* 主色调的深色 */
|
||||
border: 1px solid rgba(61, 124, 244, 0.2);
|
||||
vertical-align: middle; /* 垂直对齐 */
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
.doc-content a {
|
||||
color: var(--primary-dark); /* 使用更深的蓝色 */
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(61, 124, 244, 0.4);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.doc-content a:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration-color: var(--primary-color);
|
||||
background-color: rgba(61, 124, 244, 0.05);
|
||||
}
|
||||
|
||||
/* 引用块样式 */
|
||||
.doc-content blockquote {
|
||||
margin: 2em 0;
|
||||
padding: 1em 1.5em;
|
||||
color: #555;
|
||||
border-left: 4px solid var(--primary-light);
|
||||
background-color: #f8faff; /* 淡蓝色背景 */
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.doc-content blockquote p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.doc-content table {
|
||||
border-collapse: separate; /* 使用 separate 以应用圆角 */
|
||||
border-spacing: 0;
|
||||
margin: 1.8rem 0;
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden; /* 应用圆角 */
|
||||
}
|
||||
|
||||
.doc-content th,
|
||||
.doc-content td {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.8em 1.2em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.doc-content tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.doc-content th {
|
||||
font-weight: 600;
|
||||
background-color: #f7f9fc; /* 更浅的表头背景 */
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.doc-content tr:nth-child(even) td {
|
||||
background-color: #fafcff; /* 斑马纹 */
|
||||
}
|
||||
|
||||
/* 提示消息增强样式 - 修改为居中模态样式 */
|
||||
.toast-notification {
|
||||
position: fixed;
|
||||
/* top: 20px; */ /* 移除顶部定位 */
|
||||
top: 50%; /* 垂直居中 */
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%); /* 水平垂直居中 */
|
||||
background-color: white; /* 使用白色背景更像弹窗 */
|
||||
color: var(--text-primary);
|
||||
padding: 2rem 2.5rem; /* 增加内边距 */
|
||||
border-radius: var(--radius-lg); /* 更大的圆角 */
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15); /* 更明显的阴影 */
|
||||
z-index: 1100;
|
||||
/* animation: fadeIn 0.3s ease-in; */ /* 可以保留或换成缩放动画 */
|
||||
animation: fadeInScale 0.3s ease-out; /* 使用新的缩放动画 */
|
||||
text-align: center;
|
||||
min-width: 300px; /* 设置最小宽度 */
|
||||
max-width: 90%;
|
||||
display: flex;
|
||||
flex-direction: column; /* 让图标和文字垂直排列 */
|
||||
align-items: center;
|
||||
gap: 1rem; /* 图标和文字之间的间距 */
|
||||
border-left: none; /* 移除之前的左边框 */
|
||||
border-top: 5px solid var(--info-color); /* 使用顶部边框指示类型 */
|
||||
}
|
||||
|
||||
/* 不同类型的顶部边框颜色 */
|
||||
.toast-notification.error {
|
||||
border-top-color: var(--danger-color);
|
||||
}
|
||||
|
||||
.toast-notification.success {
|
||||
border-top-color: var(--success-color);
|
||||
}
|
||||
|
||||
.toast-notification.info {
|
||||
border-top-color: var(--info-color);
|
||||
}
|
||||
|
||||
/* 图标样式 */
|
||||
.toast-notification i {
|
||||
font-size: 2.5rem; /* 增大图标 */
|
||||
margin-bottom: 0.5rem; /* 图标下方间距 */
|
||||
}
|
||||
|
||||
.toast-notification.error i {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.toast-notification.success i {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.toast-notification.info i {
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
/* 消息文本样式 */
|
||||
.toast-notification span {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 淡出动画 */
|
||||
.toast-notification.fade-out {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9); /* 淡出时缩小 */
|
||||
transition: opacity 0.3s ease-out, transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 新增:淡入和缩放动画 */
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ... 其他样式 ... */
|
||||
|
||||
/* 表格通用样式 */
|
||||
.admin-container table {
|
||||
/* ... (现有表格样式) ... */
|
||||
}
|
||||
|
||||
/* Docker 状态表格特定样式 */
|
||||
#dockerStatusTable {
|
||||
table-layout: fixed; /* 固定表格布局,让列宽设定生效 */
|
||||
width: 100%;
|
||||
border-collapse: collapse; /* 合并边框 */
|
||||
margin-top: 1rem; /* 与上方元素保持距离 */
|
||||
}
|
||||
|
||||
#dockerStatusTable th,
|
||||
#dockerStatusTable td {
|
||||
padding: 0.8rem 1rem; /* 统一内边距 */
|
||||
border: 1px solid var(--border-light, #e5e7eb); /* 添加边框 */
|
||||
vertical-align: middle;
|
||||
font-size: 0.9rem; /* 稍小字体 */
|
||||
}
|
||||
|
||||
#dockerStatusTable thead th {
|
||||
background-color: var(--table-header-bg, #f9fafb); /* 表头背景色 */
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-weight: 600; /* 表头字体加粗 */
|
||||
position: sticky; /* 尝试粘性定位,如果表格滚动 */
|
||||
top: 0; /* 配合 sticky */
|
||||
z-index: 1; /* 确保表头在加载提示之上 */
|
||||
}
|
||||
|
||||
/* 为 ID 列设置较窄宽度 */
|
||||
#dockerStatusTable th:nth-child(1),
|
||||
#dockerStatusTable td:nth-child(1) {
|
||||
width: 120px; /* 或 10% */
|
||||
}
|
||||
|
||||
/* 为 名称 列设置最大宽度和文本溢出处理 */
|
||||
#dockerStatusTable th:nth-child(2),
|
||||
#dockerStatusTable td:nth-child(2) {
|
||||
max-width: 200px; /* 根据需要调整 */
|
||||
width: 25%; /* 尝试百分比宽度 */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 为 镜像 列设置最大宽度和文本溢出处理 */
|
||||
#dockerStatusTable th:nth-child(3),
|
||||
#dockerStatusTable td:nth-child(3) {
|
||||
max-width: 300px; /* 可以比名称宽一些 */
|
||||
width: 35%; /* 尝试百分比宽度 */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 为 状态 列设置宽度 */
|
||||
#dockerStatusTable th:nth-child(4),
|
||||
#dockerStatusTable td:nth-child(4) {
|
||||
width: 100px; /* 或 10% */
|
||||
text-align: center; /* 状态居中 */
|
||||
}
|
||||
|
||||
/* 为 操作 列设置宽度 */
|
||||
#dockerStatusTable th:nth-child(5),
|
||||
#dockerStatusTable td:nth-child(5) {
|
||||
width: 150px; /* 或 20% */
|
||||
text-align: center; /* 操作按钮居中 */
|
||||
}
|
||||
|
||||
/* 确保 title 属性的提示能正常显示 */
|
||||
#dockerStatusTable td:nth-child(2),
|
||||
#dockerStatusTable td:nth-child(3) {
|
||||
cursor: default; /* 或 help,提示用户悬停可查看完整信息 */
|
||||
}
|
||||
|
||||
/* 加载提示行样式调整 */
|
||||
#dockerStatusTable .loading-container td {
|
||||
background-color: rgba(255, 255, 255, 0.8); /* 半透明背景,避免完全遮挡 */
|
||||
padding: 2rem 0; /* 增加垂直内边距 */
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- 新增:操作下拉菜单样式 --- */
|
||||
.action-cell {
|
||||
position: relative; /* 确保下拉菜单相对于单元格定位 */
|
||||
text-align: center; /* 让按钮居中 */
|
||||
}
|
||||
|
||||
.action-dropdown .dropdown-toggle {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.85rem; /* 按钮字体小一点 */
|
||||
background-color: var(--container-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.action-dropdown .dropdown-toggle:hover,
|
||||
.action-dropdown .dropdown-toggle:focus {
|
||||
background-color: var(--background-color);
|
||||
border-color: var(--border-dark);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-dropdown .dropdown-menu {
|
||||
min-width: 160px; /* 设置最小宽度 */
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-light);
|
||||
padding: 0.5rem 0;
|
||||
margin-top: 0.25rem; /* 微调与按钮的距离 */
|
||||
}
|
||||
|
||||
.action-dropdown .dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 1.2rem; /* 调整内边距 */
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-dropdown .dropdown-item:hover {
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-dropdown .dropdown-item i {
|
||||
width: 16px; /* 固定图标宽度 */
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-dropdown .dropdown-item:hover i {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.action-dropdown .dropdown-item.text-danger,
|
||||
.action-dropdown .dropdown-item.text-danger:hover {
|
||||
color: var(--danger-color) !important;
|
||||
}
|
||||
.action-dropdown .dropdown-item.text-danger i {
|
||||
color: var(--danger-color) !important;
|
||||
}
|
||||
/* --- 结束:操作下拉菜单样式 --- */
|
||||
|
||||
|
||||
/* --- 新增:详情弹窗表格样式 --- */
|
||||
.details-swal-popup .details-table-container {
|
||||
max-height: 60vh; /* 限制最大高度,出现滚动条 */
|
||||
overflow-y: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.details-swal-popup .details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.details-swal-popup .details-table thead th {
|
||||
background-color: var(--table-header-bg, #f9fafb);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-weight: 600;
|
||||
text-align: left; /* 确保表头左对齐 */
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 2px solid var(--border-dark, #e5e7eb);
|
||||
position: sticky; /* 表头粘性定位 */
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.details-swal-popup .details-table tbody td {
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid var(--border-light, #e5e7eb);
|
||||
vertical-align: middle;
|
||||
color: var(--text-primary);
|
||||
text-align: left; /* 确保单元格左对齐 */
|
||||
}
|
||||
|
||||
.details-swal-popup .details-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.details-swal-popup .details-table tbody tr:hover td {
|
||||
background-color: var(--background-color, #f8f9fa);
|
||||
}
|
||||
|
||||
.details-swal-popup .details-table .badge {
|
||||
font-size: 0.8rem; /* 状态徽章字体稍小 */
|
||||
}
|
||||
/* --- 结束:详情弹窗表格样式 --- */
|
||||
|
||||
/* --- 新增:资源详情表格特殊样式 (两列) --- */
|
||||
.details-swal-popup .resource-details-table td.label {
|
||||
font-weight: 600; /* 标签加粗 */
|
||||
width: 40%; /* 设定标签列宽度 */
|
||||
color: var(--text-secondary); /* 标签颜色稍浅 */
|
||||
padding-right: 1.5rem; /* 标签和值之间的距离 */
|
||||
white-space: nowrap; /* 防止标签换行 */
|
||||
}
|
||||
|
||||
.details-swal-popup .resource-details-table td {
|
||||
border-bottom: 1px dashed var(--border-light, #e5e7eb); /* 使用虚线分隔行 */
|
||||
}
|
||||
/* --- 结束:资源详情表格特殊样式 --- */
|
||||
|
||||
/* --- 新增:资源详情类 Excel 表格样式 --- */
|
||||
.details-swal-popup .resource-details-excel {
|
||||
table-layout: fixed; /* 固定布局 */
|
||||
width: 100%;
|
||||
margin-top: 0; /* 移除与容器的顶部距离 */
|
||||
}
|
||||
|
||||
.details-swal-popup .resource-details-excel thead th {
|
||||
text-align: center; /* 表头居中 */
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border-dark, #dee2e6); /* 使用更深的边框 */
|
||||
padding: 0.6rem 0.5rem; /* 调整内边距 */
|
||||
}
|
||||
|
||||
.details-swal-popup .resource-details-excel tbody td {
|
||||
text-align: center; /* 数据居中 */
|
||||
padding: 0.6rem 0.5rem;
|
||||
border: 1px solid var(--border-color, #dee2e6);
|
||||
word-break: break-all; /* 允许长内容换行 */
|
||||
}
|
||||
/* --- 结束:资源详情类 Excel 表格样式 --- */
|
||||
|
||||
/* ... 其他样式 ... */
|
||||
Reference in New Issue
Block a user