Initial commit
This commit is contained in:
commit
2a84528345
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
|
@ -0,0 +1,132 @@
|
||||||
|
# Pingping 监控平台
|
||||||
|
|
||||||
|
Pingping 是一个简单易用的自部署监控平台,用于监控 HTTP 和 TCP 服务的可用性。它可以定时检测服务是否正常运行,并在服务宕机时发送通知。
|
||||||
|
|
||||||
|
## 功能特点
|
||||||
|
|
||||||
|
- 支持 HTTP 和 TCP 服务监控
|
||||||
|
- 可自定义检测间隔、超时时间和期望的返回值
|
||||||
|
- 服务宕机时自动记录 DNS 解析和 traceroute 结果
|
||||||
|
- 支持飞书和邮件通知
|
||||||
|
- 简洁美观的用户界面
|
||||||
|
- 详细的监控历史记录
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 前端:Vue 3 + TypeScript + Vite + Element Plus
|
||||||
|
- 后端:Node.js + TypeScript + Koa + TypeORM
|
||||||
|
- 数据库:SQLite(可配置为 MySQL)
|
||||||
|
- 进程管理:PM2
|
||||||
|
|
||||||
|
## 安装与使用
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js 14+
|
||||||
|
- pnpm
|
||||||
|
- PM2 (可选,用于生产环境)
|
||||||
|
|
||||||
|
### 安装步骤
|
||||||
|
|
||||||
|
1. 克隆项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/pingping.git
|
||||||
|
cd pingping
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装后端依赖
|
||||||
|
cd backend
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 安装前端依赖
|
||||||
|
cd ../frontend
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 构建项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建后端
|
||||||
|
cd ../backend
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
cd ../frontend
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 配置环境变量(可选)
|
||||||
|
|
||||||
|
如果需要使用邮件或飞书通知,请设置相应的环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 飞书通知
|
||||||
|
export FEISHU_WEBHOOK=your_feishu_webhook_url
|
||||||
|
|
||||||
|
# 邮件通知
|
||||||
|
export EMAIL_HOST=smtp.example.com
|
||||||
|
export EMAIL_PORT=587
|
||||||
|
export EMAIL_SECURE=false
|
||||||
|
export EMAIL_USER=your_email@example.com
|
||||||
|
export EMAIL_PASS=your_password
|
||||||
|
export EMAIL_FROM=pingping@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 启动服务
|
||||||
|
|
||||||
|
开发环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动后端
|
||||||
|
cd ../backend
|
||||||
|
pnpm run dev
|
||||||
|
|
||||||
|
# 启动前端(新终端)
|
||||||
|
cd ../frontend
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境(使用 PM2):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 PM2
|
||||||
|
pnpm install -g pm2
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
cd ..
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 访问应用
|
||||||
|
|
||||||
|
开发环境:
|
||||||
|
- 前端:http://localhost:3000
|
||||||
|
- 后端:http://localhost:2070
|
||||||
|
|
||||||
|
生产环境:
|
||||||
|
- 前端:http://localhost:3000
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
1. 添加监控项
|
||||||
|
- 点击"监控项"页面中的"添加监控项"按钮
|
||||||
|
- 填写监控项信息(名称、类型、主机、端口等)
|
||||||
|
- 点击"确定"保存
|
||||||
|
|
||||||
|
2. 添加通知配置
|
||||||
|
- 点击"通知设置"页面中的"添加通知"按钮
|
||||||
|
- 选择通知类型(飞书或邮件)
|
||||||
|
- 填写相关配置信息
|
||||||
|
- 点击"确定"保存
|
||||||
|
|
||||||
|
3. 查看监控状态
|
||||||
|
- 在"仪表盘"页面可以查看所有监控项的状态
|
||||||
|
- 点击"详情"可以查看监控项的详细信息和历史记录
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
|
@ -0,0 +1,28 @@
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# 创建工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# 复制 package.json 和 pnpm-lock.yaml
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 编译 TypeScript
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# 创建数据目录
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 2070
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["node", "dist/index.js"]
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"host": "smtp.feishu.cn",
|
||||||
|
"port": 465,
|
||||||
|
"secure": true,
|
||||||
|
"user": "liwei@marsway.red",
|
||||||
|
"pass": "capa@372099lvXX",
|
||||||
|
"from": "liwei@marsway.red"
|
||||||
|
}
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "ts-node src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"watch": "tsc -w",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "Pingping - A simple monitoring platform",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/koa": "^2.15.0",
|
||||||
|
"@types/koa-bodyparser": "^4.3.12",
|
||||||
|
"@types/koa-router": "^7.4.8",
|
||||||
|
"@types/node": "^22.14.1",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"better-sqlite3": "^11.9.1",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"express-validator": "^7.2.1",
|
||||||
|
"koa": "^2.16.1",
|
||||||
|
"koa-bodyparser": "^4.4.1",
|
||||||
|
"koa-cors": "^0.0.16",
|
||||||
|
"koa-logger": "^3.2.1",
|
||||||
|
"koa-router": "^13.0.1",
|
||||||
|
"nodemailer": "^6.10.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typeorm": "^0.3.22",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/koa-cors": "^0.0.6",
|
||||||
|
"@types/koa-logger": "^3.1.5"
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,3 @@
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- better-sqlite3
|
||||||
|
- sqlite3
|
|
@ -0,0 +1,29 @@
|
||||||
|
export const config = {
|
||||||
|
server: {
|
||||||
|
port: 2070,
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
type: 'sqlite',
|
||||||
|
path: './data/pingping.sqlite',
|
||||||
|
logging: false,
|
||||||
|
},
|
||||||
|
monitoring: {
|
||||||
|
defaultInterval: 60, // 默认检测间隔(秒)
|
||||||
|
failureThreshold: 3, // 连续失败多少次后发送通知
|
||||||
|
},
|
||||||
|
notification: {
|
||||||
|
feishu: {
|
||||||
|
webhook: process.env.FEISHU_WEBHOOK || '',
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
host: process.env.EMAIL_HOST || '',
|
||||||
|
port: parseInt(process.env.EMAIL_PORT || '587', 10),
|
||||||
|
secure: process.env.EMAIL_SECURE === 'true',
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_USER || '',
|
||||||
|
pass: process.env.EMAIL_PASS || '',
|
||||||
|
},
|
||||||
|
from: process.env.EMAIL_FROM || 'pingping@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,217 @@
|
||||||
|
import Router from 'koa-router';
|
||||||
|
import { Monitor, MonitorType } from '../models/Monitor';
|
||||||
|
import { MonitorResult } from '../models/MonitorResult';
|
||||||
|
import { monitoringService } from '../services/monitoringService';
|
||||||
|
import { AppDataSource } from '../index';
|
||||||
|
import { DeepPartial } from 'typeorm';
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
// 定义请求体接口
|
||||||
|
interface MonitorRequestBody {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
expectedStatus?: number;
|
||||||
|
expectedContent?: string;
|
||||||
|
timeout?: number;
|
||||||
|
interval?: number;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有监控项
|
||||||
|
router.get('/', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const monitorRepository = AppDataSource.getRepository(Monitor);
|
||||||
|
const monitors = await monitorRepository.find({ order: { id: 'DESC' } });
|
||||||
|
console.log('获取监控项:', monitors);
|
||||||
|
ctx.body = { success: true, data: monitors };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: '获取监控项失败', error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取单个监控项
|
||||||
|
router.get('/:id', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(ctx.params.id, 10);
|
||||||
|
const monitorRepository = AppDataSource.getRepository(Monitor);
|
||||||
|
const monitor = await monitorRepository.findOne({ where: { id } });
|
||||||
|
|
||||||
|
if (!monitor) {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = { success: false, message: '监控项不存在' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = { success: true, data: monitor };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: '获取监控项失败', error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建监控项
|
||||||
|
router.post('/', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const monitorData = ctx.request.body as MonitorRequestBody;
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!monitorData.name || !monitorData.type || !monitorData.host) {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = { success: false, message: '缺少必要参数' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证监控类型
|
||||||
|
if (!Object.values(MonitorType).includes(monitorData.type as MonitorType)) {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = { success: false, message: '无效的监控类型' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monitorRepository = AppDataSource.getRepository(Monitor);
|
||||||
|
|
||||||
|
// 创建新的监控项,确保不包含 ID 字段,让数据库自动生成
|
||||||
|
const { id, ...monitorDataWithoutId } = monitorData as any;
|
||||||
|
const newMonitor = monitorRepository.create(monitorDataWithoutId as DeepPartial<Monitor>);
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
const savedMonitor = await monitorRepository.save(newMonitor);
|
||||||
|
console.log('创建的监控项:', savedMonitor);
|
||||||
|
|
||||||
|
// 启动监控
|
||||||
|
monitoringService.startMonitoring(savedMonitor);
|
||||||
|
|
||||||
|
ctx.status = 201;
|
||||||
|
ctx.body = { success: true, data: savedMonitor };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: `创建监控项失败: ${error.message}` };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新监控项
|
||||||
|
router.put('/:id', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(ctx.params.id, 10);
|
||||||
|
const monitorData = ctx.request.body as MonitorRequestBody;
|
||||||
|
const monitorRepository = AppDataSource.getRepository(Monitor);
|
||||||
|
|
||||||
|
// 检查监控项是否存在
|
||||||
|
const monitor = await monitorRepository.findOne({ where: { id } });
|
||||||
|
if (!monitor) {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = { success: false, message: '监控项不存在' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查激活状态是否变化
|
||||||
|
const activeChanged = monitor.active !== monitorData.active;
|
||||||
|
|
||||||
|
// 更新监控项
|
||||||
|
Object.assign(monitor, monitorData);
|
||||||
|
await monitorRepository.save(monitor);
|
||||||
|
|
||||||
|
// 如果激活状态变化,处理监控任务
|
||||||
|
if (activeChanged) {
|
||||||
|
if (monitor.active) {
|
||||||
|
// 如果激活,启动监控
|
||||||
|
monitoringService.startMonitoring(monitor);
|
||||||
|
} else {
|
||||||
|
// 如果取消激活,停止监控
|
||||||
|
monitoringService.stopMonitoring(monitor.id);
|
||||||
|
}
|
||||||
|
} else if (monitor.active) {
|
||||||
|
// 如果监控项处于激活状态且其他属性变化,重启监控
|
||||||
|
monitoringService.stopMonitoring(monitor.id);
|
||||||
|
monitoringService.startMonitoring(monitor);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = { success: true, data: monitor };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: `更新监控项失败: ${error.message}` };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除监控项
|
||||||
|
router.delete('/:id', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(ctx.params.id, 10);
|
||||||
|
const monitorRepository = AppDataSource.getRepository(Monitor);
|
||||||
|
const resultRepository = AppDataSource.getRepository(MonitorResult);
|
||||||
|
|
||||||
|
// 检查监控项是否存在
|
||||||
|
const monitor = await monitorRepository.findOne({ where: { id } });
|
||||||
|
if (!monitor) {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = { success: false, message: '监控项不存在' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止监控
|
||||||
|
monitoringService.stopMonitoring(id);
|
||||||
|
|
||||||
|
// 删除相关的监控结果
|
||||||
|
await resultRepository.delete({ monitor: { id } });
|
||||||
|
|
||||||
|
// 删除监控项
|
||||||
|
await monitorRepository.remove(monitor);
|
||||||
|
|
||||||
|
ctx.body = { success: true, message: '监控项已删除' };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: `删除监控项失败: ${error.message}` };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取监控项的历史记录
|
||||||
|
router.get('/:id/results', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(ctx.params.id, 10);
|
||||||
|
const limit = parseInt(ctx.query.limit as string || '100', 10);
|
||||||
|
|
||||||
|
const resultRepository = AppDataSource.getRepository(MonitorResult);
|
||||||
|
const results = await resultRepository
|
||||||
|
.createQueryBuilder('result')
|
||||||
|
.where('result.monitorId = :id', { id })
|
||||||
|
.orderBy('result.createdAt', 'DESC')
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
ctx.body = { success: true, data: results };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: '获取监控记录失败', error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 手动触发监控检测
|
||||||
|
router.post('/:id/check', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(ctx.params.id, 10);
|
||||||
|
const monitorRepository = AppDataSource.getRepository(Monitor);
|
||||||
|
|
||||||
|
// 检查监控项是否存在
|
||||||
|
const monitor = await monitorRepository.findOne({ where: { id } });
|
||||||
|
if (!monitor) {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = { success: false, message: '监控项不存在' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行监控检测
|
||||||
|
const result = await monitoringService.checkMonitor(monitor);
|
||||||
|
|
||||||
|
ctx.body = { success: true, data: result };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: '执行监控检测失败', error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,154 @@
|
||||||
|
import Router from 'koa-router';
|
||||||
|
import { Notification, NotificationType } from '../models/Notification';
|
||||||
|
import { notificationService } from '../services/notificationService';
|
||||||
|
import { AppDataSource } from '../index';
|
||||||
|
import { DeepPartial } from 'typeorm';
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
// 定义请求体接口
|
||||||
|
interface NotificationRequestBody {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
config: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有通知配置
|
||||||
|
router.get('/', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const notificationRepository = AppDataSource.getRepository(Notification);
|
||||||
|
const notifications = await notificationRepository.find({ order: { id: 'DESC' } });
|
||||||
|
ctx.body = { success: true, data: notifications };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: '获取通知配置失败', error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取单个通知配置
|
||||||
|
router.get('/:id', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(ctx.params.id, 10);
|
||||||
|
const notificationRepository = AppDataSource.getRepository(Notification);
|
||||||
|
const notification = await notificationRepository.findOne({ where: { id } });
|
||||||
|
|
||||||
|
if (!notification) {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = { success: false, message: '通知配置不存在' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = { success: true, data: notification };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: '获取通知配置失败', error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建通知配置
|
||||||
|
router.post('/', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const notificationData = ctx.request.body as NotificationRequestBody;
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!notificationData.name || !notificationData.type || !notificationData.config) {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = { success: false, message: '缺少必要参数' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证通知类型
|
||||||
|
if (!Object.values(NotificationType).includes(notificationData.type as NotificationType)) {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = { success: false, message: '无效的通知类型' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationRepository = AppDataSource.getRepository(Notification);
|
||||||
|
const newNotification = notificationRepository.create(notificationData as DeepPartial<Notification>);
|
||||||
|
await notificationRepository.save(newNotification);
|
||||||
|
|
||||||
|
ctx.status = 201;
|
||||||
|
ctx.body = { success: true, data: newNotification };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: '创建通知配置失败', error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新通知配置
|
||||||
|
router.put('/:id', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(ctx.params.id, 10);
|
||||||
|
const notificationData = ctx.request.body as DeepPartial<Notification>;
|
||||||
|
const notificationRepository = AppDataSource.getRepository(Notification);
|
||||||
|
|
||||||
|
// 检查通知配置是否存在
|
||||||
|
const notification = await notificationRepository.findOne({ where: { id } });
|
||||||
|
if (!notification) {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = { success: false, message: '通知配置不存在' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新通知配置
|
||||||
|
notificationRepository.merge(notification, notificationData);
|
||||||
|
await notificationRepository.save(notification);
|
||||||
|
|
||||||
|
ctx.body = { success: true, data: notification };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: '更新通知配置失败', error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除通知配置
|
||||||
|
router.delete('/:id', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(ctx.params.id, 10);
|
||||||
|
const notificationRepository = AppDataSource.getRepository(Notification);
|
||||||
|
|
||||||
|
// 检查通知配置是否存在
|
||||||
|
const notification = await notificationRepository.findOne({ where: { id } });
|
||||||
|
if (!notification) {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = { success: false, message: '通知配置不存在' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除通知配置
|
||||||
|
await notificationRepository.remove(notification);
|
||||||
|
|
||||||
|
ctx.body = { success: true, message: '通知配置已删除' };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: '删除通知配置失败', error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 测试通知
|
||||||
|
router.post('/:id/test', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(ctx.params.id, 10);
|
||||||
|
const notificationRepository = AppDataSource.getRepository(Notification);
|
||||||
|
|
||||||
|
// 检查通知配置是否存在
|
||||||
|
const notification = await notificationRepository.findOne({ where: { id } });
|
||||||
|
if (!notification) {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = { success: false, message: '通知配置不存在' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送测试通知
|
||||||
|
const result = await notificationService.sendTestNotification(notification);
|
||||||
|
|
||||||
|
ctx.body = { success: true, data: result };
|
||||||
|
} catch (error: any) {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { success: false, message: '发送测试通知失败', error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,95 @@
|
||||||
|
import Koa from 'koa';
|
||||||
|
import Router from 'koa-router';
|
||||||
|
import bodyParser from 'koa-bodyparser';
|
||||||
|
import cors from 'koa-cors';
|
||||||
|
import logger from 'koa-logger';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { config } from './config';
|
||||||
|
import monitoringRoutes from './controllers/monitoring';
|
||||||
|
import notificationRoutes from './controllers/notification';
|
||||||
|
import settingsRoutes from './routes/settings';
|
||||||
|
|
||||||
|
// 创建Koa应用
|
||||||
|
const app = new Koa();
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
// 错误处理中间件
|
||||||
|
app.use(async (ctx, next) => {
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} catch (err: any) {
|
||||||
|
// 记录错误
|
||||||
|
console.error('服务器错误:', err);
|
||||||
|
|
||||||
|
// 设置状态码和错误信息
|
||||||
|
ctx.status = err.status || 500;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: err.message || '服务器内部错误',
|
||||||
|
error: process.env.NODE_ENV === 'production' ? undefined : {
|
||||||
|
stack: err.stack,
|
||||||
|
name: err.name
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 触发应用级错误事件
|
||||||
|
ctx.app.emit('error', err, ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求日志中间件
|
||||||
|
app.use(logger((str: string) => {
|
||||||
|
console.log(`[${new Date().toISOString()}] ${str}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
app.use(cors());
|
||||||
|
app.use(bodyParser());
|
||||||
|
|
||||||
|
// 路由
|
||||||
|
router.use('/api/monitoring', monitoringRoutes.routes());
|
||||||
|
router.use('/api/notification', notificationRoutes.routes());
|
||||||
|
router.use('/api/settings', settingsRoutes.routes());
|
||||||
|
|
||||||
|
// 使用路由中间件
|
||||||
|
app.use(router.routes());
|
||||||
|
app.use(router.allowedMethods());
|
||||||
|
|
||||||
|
// 应用级错误事件监听
|
||||||
|
app.on('error', (err, ctx) => {
|
||||||
|
console.error('应用错误:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确定实体文件扩展名(开发环境使用.ts,生产环境使用.js)
|
||||||
|
const extension = process.env.NODE_ENV === 'production' ? 'js' : 'ts';
|
||||||
|
|
||||||
|
// 创建数据库连接
|
||||||
|
export const AppDataSource = new DataSource({
|
||||||
|
type: 'better-sqlite3',
|
||||||
|
database: config.database.path,
|
||||||
|
entities: [__dirname + `/models/*.${extension}`],
|
||||||
|
synchronize: true,
|
||||||
|
logging: config.database.logging
|
||||||
|
});
|
||||||
|
|
||||||
|
// 数据库连接
|
||||||
|
async function startServer() {
|
||||||
|
try {
|
||||||
|
// 初始化数据库连接
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
|
||||||
|
console.log('数据库连接成功');
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
app.listen(config.server.port, () => {
|
||||||
|
console.log(`服务器运行在 http://localhost:${config.server.port}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('启动服务器失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看应用启动时的监控任务初始化逻辑
|
||||||
|
console.log('应用启动时的监控任务初始化逻辑:', monitoringRoutes);
|
||||||
|
|
||||||
|
startServer();
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
|
||||||
|
import { MonitorResult } from './MonitorResult';
|
||||||
|
|
||||||
|
export enum MonitorType {
|
||||||
|
HTTP = 'http',
|
||||||
|
HTTPS = 'https',
|
||||||
|
TCP = 'tcp',
|
||||||
|
PING = 'ping',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MonitorStatus {
|
||||||
|
UP = 'up',
|
||||||
|
DOWN = 'down',
|
||||||
|
PENDING = 'pending',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Monitor {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'simple-enum',
|
||||||
|
enum: MonitorType,
|
||||||
|
})
|
||||||
|
type: MonitorType;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
host: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
port: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
path?: string;
|
||||||
|
|
||||||
|
@Column({ default: 5000 })
|
||||||
|
timeout: number; // 超时时间(毫秒)
|
||||||
|
|
||||||
|
@Column({ default: 60 })
|
||||||
|
interval: number; // 检测间隔(秒)
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
expectedStatus?: number; // HTTP 状态码
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
expectedContent?: string; // 期望的返回内容
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'simple-enum',
|
||||||
|
enum: MonitorStatus,
|
||||||
|
default: MonitorStatus.PENDING,
|
||||||
|
})
|
||||||
|
status: MonitorStatus;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
consecutiveFailures: number; // 连续失败次数
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
active: boolean; // 是否激活
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
lastCheckTime?: Date; // 最后检测时间
|
||||||
|
|
||||||
|
@OneToMany(() => MonitorResult, (result) => result.monitor)
|
||||||
|
results: MonitorResult[];
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { Monitor, MonitorStatus } from './Monitor';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class MonitorResult {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@ManyToOne(() => Monitor, (monitor) => monitor.results)
|
||||||
|
monitor: Monitor;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'simple-enum',
|
||||||
|
enum: MonitorStatus,
|
||||||
|
})
|
||||||
|
status: MonitorStatus;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
responseTime?: number; // 响应时间(毫秒)
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
statusCode?: number; // HTTP 状态码
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
responseBody?: string; // 响应内容
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
error?: string; // 错误信息
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
dnsResolution?: string; // DNS 解析结果
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
traceroute?: string; // traceroute 结果
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export enum NotificationType {
|
||||||
|
FEISHU = 'feishu',
|
||||||
|
EMAIL = 'email',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Notification {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'simple-enum',
|
||||||
|
enum: NotificationType,
|
||||||
|
})
|
||||||
|
type: NotificationType;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
config: string; // JSON 格式的配置,如 webhook URL 或邮箱地址
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
|
@ -0,0 +1,591 @@
|
||||||
|
import Router from 'koa-router';
|
||||||
|
import { Context } from 'koa';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
// 定义请求体类型
|
||||||
|
interface EmailSettings {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
from: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationSettings {
|
||||||
|
throttle: number;
|
||||||
|
retryCount: number;
|
||||||
|
sendRecoveryNotification: boolean;
|
||||||
|
sendStatusChangeOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogSettings {
|
||||||
|
level: string;
|
||||||
|
retention: number;
|
||||||
|
filePath: string;
|
||||||
|
enableConsole: boolean;
|
||||||
|
enableFile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
const CONFIG_DIR = path.join(__dirname, '../../config');
|
||||||
|
const EMAIL_CONFIG_PATH = path.join(CONFIG_DIR, 'email.json');
|
||||||
|
const NOTIFICATION_CONFIG_PATH = path.join(CONFIG_DIR, 'notification.json');
|
||||||
|
const LOG_CONFIG_PATH = path.join(CONFIG_DIR, 'logs.json');
|
||||||
|
|
||||||
|
// 确保配置目录存在
|
||||||
|
if (!fs.existsSync(CONFIG_DIR)) {
|
||||||
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取邮件配置
|
||||||
|
const readEmailConfig = (): EmailSettings => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(EMAIL_CONFIG_PATH)) {
|
||||||
|
const configData = fs.readFileSync(EMAIL_CONFIG_PATH, 'utf8');
|
||||||
|
return JSON.parse(configData);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
host: '',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
user: '',
|
||||||
|
pass: '',
|
||||||
|
from: ''
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取邮件配置失败:', error);
|
||||||
|
return {
|
||||||
|
host: '',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
user: '',
|
||||||
|
pass: '',
|
||||||
|
from: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 读取通知配置
|
||||||
|
const readNotificationConfig = (): NotificationSettings => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(NOTIFICATION_CONFIG_PATH)) {
|
||||||
|
const configData = fs.readFileSync(NOTIFICATION_CONFIG_PATH, 'utf8');
|
||||||
|
return JSON.parse(configData);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
throttle: 15,
|
||||||
|
retryCount: 3,
|
||||||
|
sendRecoveryNotification: true,
|
||||||
|
sendStatusChangeOnly: true
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取通知配置失败:', error);
|
||||||
|
return {
|
||||||
|
throttle: 15,
|
||||||
|
retryCount: 3,
|
||||||
|
sendRecoveryNotification: true,
|
||||||
|
sendStatusChangeOnly: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 读取日志配置
|
||||||
|
const readLogConfig = (): LogSettings => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(LOG_CONFIG_PATH)) {
|
||||||
|
const configData = fs.readFileSync(LOG_CONFIG_PATH, 'utf8');
|
||||||
|
return JSON.parse(configData);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
level: 'info',
|
||||||
|
retention: 30,
|
||||||
|
filePath: path.join(__dirname, '../../logs'),
|
||||||
|
enableConsole: true,
|
||||||
|
enableFile: true
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取日志配置失败:', error);
|
||||||
|
return {
|
||||||
|
level: 'info',
|
||||||
|
retention: 30,
|
||||||
|
filePath: path.join(__dirname, '../../logs'),
|
||||||
|
enableConsole: true,
|
||||||
|
enableFile: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存邮件配置
|
||||||
|
const saveEmailConfig = (config: EmailSettings): boolean => {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(EMAIL_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存邮件配置失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存通知配置
|
||||||
|
const saveNotificationConfig = (config: NotificationSettings): boolean => {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(NOTIFICATION_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存通知配置失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存日志配置
|
||||||
|
const saveLogConfig = (config: LogSettings): boolean => {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(LOG_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存日志配置失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取邮件配置
|
||||||
|
router.get('/email', async (ctx: Context) => {
|
||||||
|
const config = readEmailConfig();
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
data: config
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存邮件配置
|
||||||
|
router.post('/email', async (ctx: Context) => {
|
||||||
|
const body = ctx.request.body as any || {};
|
||||||
|
|
||||||
|
const host = String(body.host || '');
|
||||||
|
const port = Number(body.port || 587);
|
||||||
|
const secure = Boolean(body.secure || false);
|
||||||
|
const user = String(body.user || '');
|
||||||
|
const pass = String(body.pass || '');
|
||||||
|
const from = String(body.from || '');
|
||||||
|
|
||||||
|
// 简单验证
|
||||||
|
if (!host || !user || !pass || !from) {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '请提供所有必填字段'
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = { host, port, secure, user, pass, from };
|
||||||
|
|
||||||
|
if (saveEmailConfig(config)) {
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
message: '邮件配置保存成功'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '邮件配置保存失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 测试邮件配置
|
||||||
|
router.post('/email/test', async (ctx: Context) => {
|
||||||
|
const body = ctx.request.body as any || {};
|
||||||
|
|
||||||
|
const host = String(body.host || '');
|
||||||
|
const port = Number(body.port || 587);
|
||||||
|
const secure = Boolean(body.secure || false);
|
||||||
|
const user = String(body.user || '');
|
||||||
|
const pass = String(body.pass || '');
|
||||||
|
|
||||||
|
// 简单验证
|
||||||
|
if (!host || !user || !pass) {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '请提供所有必填字段'
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建测试连接
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
auth: {
|
||||||
|
user,
|
||||||
|
pass
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证连接配置
|
||||||
|
await transporter.verify();
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
message: '邮件服务器连接成功'
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('测试邮件配置失败:', error);
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: `邮件配置测试失败: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送测试邮件
|
||||||
|
router.post('/email/send-test', async (ctx: Context) => {
|
||||||
|
const body = ctx.request.body as any || {};
|
||||||
|
|
||||||
|
const host = String(body.host || '');
|
||||||
|
const port = Number(body.port || 587);
|
||||||
|
const secure = Boolean(body.secure || false);
|
||||||
|
const user = String(body.user || '');
|
||||||
|
const pass = String(body.pass || '');
|
||||||
|
const from = String(body.from || '');
|
||||||
|
const to = String(body.to || '');
|
||||||
|
|
||||||
|
// 简单验证
|
||||||
|
if (!host || !user || !pass || !from || !to) {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '请提供所有必填字段'
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建邮件发送器
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
auth: {
|
||||||
|
user,
|
||||||
|
pass
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送测试邮件
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: 'Pingping 监控系统 - 测试邮件',
|
||||||
|
text: '这是一封测试邮件,用于验证 Pingping 监控系统的邮件发送功能是否正常工作。',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; padding: 20px; max-width: 600px; margin: 0 auto; border: 1px solid #eee; border-radius: 5px;">
|
||||||
|
<h2 style="color: #333;">Pingping 监控系统</h2>
|
||||||
|
<p>您好,</p>
|
||||||
|
<p>这是一封测试邮件,用于验证 Pingping 监控系统的邮件发送功能是否正常工作。</p>
|
||||||
|
<p>如果您收到此邮件,则表示邮件配置正确,系统可以正常发送通知邮件。</p>
|
||||||
|
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #999;">
|
||||||
|
<p>此邮件由系统自动发送,请勿回复。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
message: '测试邮件发送成功',
|
||||||
|
data: {
|
||||||
|
messageId: info.messageId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('发送测试邮件失败:', error);
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: `发送测试邮件失败: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取通知设置
|
||||||
|
router.get('/notification', async (ctx: Context) => {
|
||||||
|
const config = readNotificationConfig();
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
data: config
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存通知设置
|
||||||
|
router.post('/notification', async (ctx: Context) => {
|
||||||
|
const body = ctx.request.body as any || {};
|
||||||
|
|
||||||
|
const throttle = Number(body.throttle || 15);
|
||||||
|
const retryCount = Number(body.retryCount || 3);
|
||||||
|
const sendRecoveryNotification = Boolean(body.sendRecoveryNotification);
|
||||||
|
const sendStatusChangeOnly = Boolean(body.sendStatusChangeOnly);
|
||||||
|
|
||||||
|
// 简单验证
|
||||||
|
if (isNaN(throttle) || isNaN(retryCount)) {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '请提供正确的数字类型字段'
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
throttle,
|
||||||
|
retryCount,
|
||||||
|
sendRecoveryNotification,
|
||||||
|
sendStatusChangeOnly
|
||||||
|
};
|
||||||
|
|
||||||
|
if (saveNotificationConfig(config)) {
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
message: '通知设置保存成功'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '通知设置保存失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取日志设置
|
||||||
|
router.get('/logs', async (ctx: Context) => {
|
||||||
|
const config = readLogConfig();
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
data: config
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存日志设置
|
||||||
|
router.post('/logs', async (ctx: Context) => {
|
||||||
|
const body = ctx.request.body as any || {};
|
||||||
|
|
||||||
|
const level = String(body.level || 'info');
|
||||||
|
const retention = Number(body.retention || 30);
|
||||||
|
const filePath = String(body.filePath || path.join(__dirname, '../../logs'));
|
||||||
|
const enableConsole = Boolean(body.enableConsole);
|
||||||
|
const enableFile = Boolean(body.enableFile);
|
||||||
|
|
||||||
|
// 简单验证
|
||||||
|
if (!level || isNaN(retention)) {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '请提供所有必填字段'
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
level,
|
||||||
|
retention,
|
||||||
|
filePath,
|
||||||
|
enableConsole,
|
||||||
|
enableFile
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果启用了文件日志,确保日志目录存在
|
||||||
|
if (enableFile && filePath) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(filePath, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建日志目录失败:', error);
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '无法创建日志目录,请检查路径权限'
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveLogConfig(config)) {
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
message: '日志设置保存成功'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '日志设置保存失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取日志统计信息
|
||||||
|
router.get('/logs/stats', async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const config = readLogConfig();
|
||||||
|
const logDir = config.filePath;
|
||||||
|
|
||||||
|
// 默认统计信息
|
||||||
|
let stats = {
|
||||||
|
size: '0 KB',
|
||||||
|
oldestDate: '',
|
||||||
|
newestDate: '',
|
||||||
|
entryCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果启用了文件日志并且目录存在
|
||||||
|
if (config.enableFile && logDir && fs.existsSync(logDir)) {
|
||||||
|
// 获取所有日志文件
|
||||||
|
const files = fs.readdirSync(logDir)
|
||||||
|
.filter(file => file.endsWith('.log'))
|
||||||
|
.map(file => ({
|
||||||
|
name: file,
|
||||||
|
path: path.join(logDir, file),
|
||||||
|
stats: fs.statSync(path.join(logDir, file))
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.stats.mtime.getTime() - b.stats.mtime.getTime());
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
// 计算总大小
|
||||||
|
const totalSize = files.reduce((sum, file) => sum + file.stats.size, 0);
|
||||||
|
stats.size = formatBytes(totalSize);
|
||||||
|
|
||||||
|
// 最早和最新日志时间
|
||||||
|
stats.oldestDate = files[0].stats.mtime.toLocaleString();
|
||||||
|
stats.newestDate = files[files.length - 1].stats.mtime.toLocaleString();
|
||||||
|
|
||||||
|
// 估计日志条目数 (简单估计,每行平均100字节)
|
||||||
|
stats.entryCount = Math.floor(totalSize / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取日志统计失败:', error);
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: `获取日志统计失败: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 下载日志
|
||||||
|
router.get('/logs/download', async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const config = readLogConfig();
|
||||||
|
const logDir = config.filePath;
|
||||||
|
|
||||||
|
// 检查日志目录是否存在
|
||||||
|
if (!config.enableFile || !logDir || !fs.existsSync(logDir)) {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '日志文件不存在'
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有日志文件
|
||||||
|
const files = fs.readdirSync(logDir)
|
||||||
|
.filter(file => file.endsWith('.log'));
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: '没有日志文件可供下载'
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单实现:将最新的日志文件发送给客户端
|
||||||
|
// 在实际应用中,你可能需要实现一个压缩所有日志的功能
|
||||||
|
const latestLog = files
|
||||||
|
.map(file => ({
|
||||||
|
name: file,
|
||||||
|
path: path.join(logDir, file),
|
||||||
|
stats: fs.statSync(path.join(logDir, file))
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())[0];
|
||||||
|
|
||||||
|
ctx.set('Content-Disposition', `attachment; filename=${latestLog.name}`);
|
||||||
|
ctx.set('Content-Type', 'text/plain');
|
||||||
|
|
||||||
|
// 发送文件
|
||||||
|
ctx.body = fs.createReadStream(latestLog.path);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('下载日志失败:', error);
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: `下载日志失败: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清除日志
|
||||||
|
router.post('/logs/clear', async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const config = readLogConfig();
|
||||||
|
const logDir = config.filePath;
|
||||||
|
|
||||||
|
// 检查日志目录是否存在
|
||||||
|
if (!config.enableFile || !logDir || !fs.existsSync(logDir)) {
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
message: '没有日志需要清除'
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有日志文件
|
||||||
|
const files = fs.readdirSync(logDir)
|
||||||
|
.filter(file => file.endsWith('.log'));
|
||||||
|
|
||||||
|
// 删除所有日志文件
|
||||||
|
for (const file of files) {
|
||||||
|
fs.unlinkSync(path.join(logDir, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
message: `已清除 ${files.length} 个日志文件`
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('清除日志失败:', error);
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = {
|
||||||
|
success: false,
|
||||||
|
message: `清除日志失败: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 工具函数:格式化字节大小
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,506 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import net from 'net';
|
||||||
|
import dns from 'dns';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { Monitor, MonitorType, MonitorStatus } from '../models/Monitor';
|
||||||
|
import { MonitorResult } from '../models/MonitorResult';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { notificationService } from './notificationService';
|
||||||
|
import { AppDataSource } from '../index';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
const execPromise = promisify(exec);
|
||||||
|
const dnsLookup = promisify(dns.lookup);
|
||||||
|
const dnsResolve = promisify(dns.resolve);
|
||||||
|
|
||||||
|
class MonitoringService {
|
||||||
|
private monitoringJobs: Map<number, NodeJS.Timeout> = new Map();
|
||||||
|
// 跟踪正在执行的 traceroute 进程
|
||||||
|
private tracerouteProcesses: Map<number, { childProcess: any, resultId: number }> = new Map();
|
||||||
|
|
||||||
|
// 启动所有监控
|
||||||
|
async startAllMonitoring() {
|
||||||
|
try {
|
||||||
|
const monitorRepository = AppDataSource.getRepository(Monitor);
|
||||||
|
const monitors = await monitorRepository.find({ where: { active: true } });
|
||||||
|
|
||||||
|
// 先清空所有现有的监控任务
|
||||||
|
this.stopAllMonitoring();
|
||||||
|
|
||||||
|
// 启动所有激活的监控项
|
||||||
|
for (const monitor of monitors) {
|
||||||
|
this.startMonitoring(monitor);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`已启动 ${monitors.length} 个监控任务`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('启动监控失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止所有监控
|
||||||
|
stopAllMonitoring() {
|
||||||
|
// 保存当前所有监控任务的ID
|
||||||
|
const monitorIds = Array.from(this.monitoringJobs.keys());
|
||||||
|
|
||||||
|
// 停止所有监控任务
|
||||||
|
for (const id of monitorIds) {
|
||||||
|
this.stopMonitoring(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`已停止所有监控任务 (${monitorIds.length} 个)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动单个监控
|
||||||
|
startMonitoring(monitor: Monitor) {
|
||||||
|
// 如果已经有监控任务,先停止
|
||||||
|
this.stopMonitoring(monitor.id);
|
||||||
|
|
||||||
|
// 确保监控项是激活状态
|
||||||
|
if (!monitor.active) {
|
||||||
|
console.log(`监控项 ${monitor.name} (ID: ${monitor.id}) 未激活,不启动监控`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的监控任务
|
||||||
|
const interval = monitor.interval * 1000; // 转换为毫秒
|
||||||
|
const job = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
// 在执行检测前,先检查监控项是否仍然存在且处于激活状态
|
||||||
|
const monitorRepository = AppDataSource.getRepository(Monitor);
|
||||||
|
const currentMonitor = await monitorRepository.findOne({ where: { id: monitor.id } });
|
||||||
|
|
||||||
|
if (!currentMonitor || !currentMonitor.active) {
|
||||||
|
// 如果监控项已被删除或设为非激活,停止监控
|
||||||
|
console.log(`监控项 ${monitor.id} 已被删除或设为非激活,停止监控`);
|
||||||
|
this.stopMonitoring(monitor.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用最新的监控项配置进行检测
|
||||||
|
await this.checkMonitor(currentMonitor);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`执行监控检测失败 (ID: ${monitor.id}):`, err);
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
|
||||||
|
// 保存监控任务
|
||||||
|
this.monitoringJobs.set(monitor.id, job);
|
||||||
|
console.log(`已启动监控: ${monitor.name} (ID: ${monitor.id}), 间隔: ${monitor.interval}秒`);
|
||||||
|
|
||||||
|
// 立即执行一次检测
|
||||||
|
this.checkMonitor(monitor).catch(err => {
|
||||||
|
console.error(`初始监控检测失败 (ID: ${monitor.id}):`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止监控
|
||||||
|
stopMonitoring(monitorId: number) {
|
||||||
|
const job = this.monitoringJobs.get(monitorId);
|
||||||
|
if (job) {
|
||||||
|
clearInterval(job);
|
||||||
|
this.monitoringJobs.delete(monitorId);
|
||||||
|
console.log(`停止监控: ${monitorId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止正在执行的 traceroute 进程
|
||||||
|
this.stopTraceroute(monitorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止 traceroute 进程
|
||||||
|
stopTraceroute(monitorId: number) {
|
||||||
|
const processInfo = this.tracerouteProcesses.get(monitorId);
|
||||||
|
if (processInfo) {
|
||||||
|
try {
|
||||||
|
// 尝试终止进程
|
||||||
|
if (processInfo.childProcess && processInfo.childProcess.kill) {
|
||||||
|
processInfo.childProcess.kill();
|
||||||
|
console.log(`停止 traceroute 进程: ${monitorId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新数据库中的结果,标记为已中断
|
||||||
|
this.updateTracerouteResult(processInfo.resultId, '监控项已停用,traceroute 已中断');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`停止 traceroute 进程失败: ${err}`);
|
||||||
|
} finally {
|
||||||
|
this.tracerouteProcesses.delete(monitorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 traceroute 结果
|
||||||
|
private async updateTracerouteResult(resultId: number, message: string) {
|
||||||
|
try {
|
||||||
|
const resultRepository = AppDataSource.getRepository(MonitorResult);
|
||||||
|
const result = await resultRepository.findOne({ where: { id: resultId } });
|
||||||
|
if (result) {
|
||||||
|
result.traceroute = message;
|
||||||
|
await resultRepository.save(result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`更新 traceroute 结果失败: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测监控项
|
||||||
|
async checkMonitor(monitor: Monitor): Promise<MonitorResult> {
|
||||||
|
const monitorRepository = AppDataSource.getRepository(Monitor);
|
||||||
|
const resultRepository = AppDataSource.getRepository(MonitorResult);
|
||||||
|
|
||||||
|
// 创建新的监控结果
|
||||||
|
const result = new MonitorResult();
|
||||||
|
result.monitor = monitor;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let isUp = false;
|
||||||
|
let statusCode: number | undefined;
|
||||||
|
let responseBody: string | undefined;
|
||||||
|
let responseTime: number | undefined;
|
||||||
|
let error: string | undefined;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// 根据监控类型执行不同的检测
|
||||||
|
if (monitor.type === MonitorType.HTTP) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`http://${monitor.host}:${monitor.port}${monitor.path || '/'}`, {
|
||||||
|
timeout: monitor.timeout,
|
||||||
|
validateStatus: () => true, // 不抛出HTTP错误
|
||||||
|
});
|
||||||
|
|
||||||
|
statusCode = response.status;
|
||||||
|
responseBody = typeof response.data === 'string'
|
||||||
|
? response.data
|
||||||
|
: JSON.stringify(response.data);
|
||||||
|
|
||||||
|
// 检查状态码
|
||||||
|
if (monitor.expectedStatus) {
|
||||||
|
isUp = statusCode === monitor.expectedStatus;
|
||||||
|
} else {
|
||||||
|
isUp = statusCode >= 200 && statusCode < 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查响应内容
|
||||||
|
if (isUp && monitor.expectedContent && responseBody) {
|
||||||
|
isUp = responseBody.includes(monitor.expectedContent);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.message;
|
||||||
|
isUp = false;
|
||||||
|
}
|
||||||
|
} else if (monitor.type === MonitorType.HTTPS) {
|
||||||
|
try {
|
||||||
|
// 创建 HTTPS 请求选项,忽略SSL证书错误
|
||||||
|
const httpsAgent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axios.get(`https://${monitor.host}:${monitor.port}${monitor.path || '/'}`, {
|
||||||
|
timeout: monitor.timeout,
|
||||||
|
validateStatus: () => true, // 不抛出HTTP错误
|
||||||
|
httpsAgent
|
||||||
|
});
|
||||||
|
|
||||||
|
statusCode = response.status;
|
||||||
|
responseBody = typeof response.data === 'string'
|
||||||
|
? response.data
|
||||||
|
: JSON.stringify(response.data);
|
||||||
|
|
||||||
|
// 检查状态码
|
||||||
|
if (monitor.expectedStatus) {
|
||||||
|
isUp = statusCode === monitor.expectedStatus;
|
||||||
|
} else {
|
||||||
|
isUp = statusCode >= 200 && statusCode < 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查响应内容
|
||||||
|
if (isUp && monitor.expectedContent && responseBody) {
|
||||||
|
isUp = responseBody.includes(monitor.expectedContent);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.message;
|
||||||
|
isUp = false;
|
||||||
|
}
|
||||||
|
} else if (monitor.type === MonitorType.TCP) {
|
||||||
|
try {
|
||||||
|
// TCP 连接测试
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
|
||||||
|
socket.setTimeout(monitor.timeout);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
isUp = true;
|
||||||
|
socket.end();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(new Error('连接超时'));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.connect(monitor.port, monitor.host);
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.message;
|
||||||
|
isUp = false;
|
||||||
|
}
|
||||||
|
} else if (monitor.type === MonitorType.PING) {
|
||||||
|
try {
|
||||||
|
// 根据操作系统选择 ping 命令
|
||||||
|
const pingCount = 4; // 发送4个包
|
||||||
|
const pingCmd = process.platform === 'win32'
|
||||||
|
? `ping -n ${pingCount} ${monitor.host}`
|
||||||
|
: `ping -c ${pingCount} ${monitor.host}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execPromise(pingCmd, { timeout: monitor.timeout });
|
||||||
|
responseBody = stdout;
|
||||||
|
|
||||||
|
// 检查 ping 结果
|
||||||
|
// Windows 和 Unix 系统的成功输出不同,需要分别处理
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// Windows: 检查是否有"丢失 = 0"或类似的文本
|
||||||
|
isUp = stdout.includes('(0% 丢失)') || stdout.includes('(0% loss)');
|
||||||
|
} else {
|
||||||
|
// Unix/Linux/Mac: 检查是否有"0% packet loss"或类似的文本
|
||||||
|
isUp = stdout.includes('0% packet loss');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUp) {
|
||||||
|
error = '目标主机无响应';
|
||||||
|
}
|
||||||
|
} catch (execErr: any) {
|
||||||
|
// 捕获 exec 执行错误,但仍然尝试解析输出
|
||||||
|
if (execErr.stdout) {
|
||||||
|
responseBody = execErr.stdout;
|
||||||
|
|
||||||
|
// 尝试从输出中判断是否有部分成功的响应
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
isUp = execErr.stdout.includes('TTL=') || execErr.stdout.includes('time=');
|
||||||
|
} else {
|
||||||
|
isUp = execErr.stdout.includes('bytes from') || execErr.stdout.includes('time=');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUp) {
|
||||||
|
error = `Ping 命令返回非零状态: ${execErr.message}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有任何输出,可能是权限问题或网络问题
|
||||||
|
error = `Ping 命令执行失败: ${execErr.message}`;
|
||||||
|
isUp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
error = `Ping 监控异常: ${err.message}`;
|
||||||
|
isUp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 更新监控结果
|
||||||
|
result.status = isUp ? MonitorStatus.UP : MonitorStatus.DOWN;
|
||||||
|
result.responseTime = responseTime;
|
||||||
|
result.statusCode = statusCode;
|
||||||
|
result.responseBody = responseBody;
|
||||||
|
result.error = error;
|
||||||
|
|
||||||
|
// 如果服务宕机,收集额外信息
|
||||||
|
if (!isUp) {
|
||||||
|
// 收集 DNS 解析信息
|
||||||
|
try {
|
||||||
|
const dnsResults = await Promise.all([
|
||||||
|
dnsLookup(monitor.host),
|
||||||
|
dnsResolve(monitor.host, 'A').catch(() => []),
|
||||||
|
dnsResolve(monitor.host, 'CNAME').catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
result.dnsResolution = JSON.stringify({
|
||||||
|
lookup: dnsResults[0],
|
||||||
|
A: dnsResults[1],
|
||||||
|
CNAME: dnsResults[2],
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`DNS 解析失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置初始 traceroute 消息
|
||||||
|
result.traceroute = "正在执行 traceroute,请稍后查看...";
|
||||||
|
|
||||||
|
// 保存监控结果
|
||||||
|
const savedResult = await resultRepository.save(result);
|
||||||
|
|
||||||
|
// 更新监控状态 - 立即设置为宕机
|
||||||
|
const oldStatus = monitor.status;
|
||||||
|
monitor.status = MonitorStatus.DOWN;
|
||||||
|
monitor.lastCheckTime = new Date();
|
||||||
|
monitor.consecutiveFailures += 1;
|
||||||
|
|
||||||
|
// 处理连续失败和通知
|
||||||
|
if (monitor.consecutiveFailures >= config.monitoring.failureThreshold) {
|
||||||
|
// 只在刚好达到阈值或之前没有发送过通知时发送通知
|
||||||
|
if (monitor.consecutiveFailures === config.monitoring.failureThreshold ||
|
||||||
|
(oldStatus === MonitorStatus.UP && monitor.consecutiveFailures > config.monitoring.failureThreshold)) {
|
||||||
|
await notificationService.sendDownNotification(monitor, result);
|
||||||
|
console.log(`发送宕机通知: ${monitor.name}, 连续失败次数: ${monitor.consecutiveFailures}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await monitorRepository.save(monitor);
|
||||||
|
|
||||||
|
// 异步执行 traceroute,不阻塞主流程
|
||||||
|
this.executeTracerouteAsync(monitor, savedResult.id);
|
||||||
|
|
||||||
|
return savedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存监控结果
|
||||||
|
await resultRepository.save(result);
|
||||||
|
|
||||||
|
// 更新监控状态
|
||||||
|
const oldStatus = monitor.status;
|
||||||
|
monitor.status = result.status;
|
||||||
|
monitor.lastCheckTime = new Date();
|
||||||
|
|
||||||
|
// 处理连续失败
|
||||||
|
if (result.status === MonitorStatus.DOWN) {
|
||||||
|
monitor.consecutiveFailures += 1;
|
||||||
|
|
||||||
|
// 连续失败达到阈值,发送通知
|
||||||
|
if (monitor.consecutiveFailures >= config.monitoring.failureThreshold) {
|
||||||
|
// 只在刚好达到阈值或之前没有发送过通知时发送通知
|
||||||
|
if (monitor.consecutiveFailures === config.monitoring.failureThreshold ||
|
||||||
|
(oldStatus === MonitorStatus.UP && monitor.consecutiveFailures > config.monitoring.failureThreshold)) {
|
||||||
|
await notificationService.sendDownNotification(monitor, result);
|
||||||
|
console.log(`发送宕机通知: ${monitor.name}, 连续失败次数: ${monitor.consecutiveFailures}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果从宕机恢复,发送恢复通知
|
||||||
|
if (oldStatus === MonitorStatus.DOWN && monitor.consecutiveFailures >= config.monitoring.failureThreshold) {
|
||||||
|
await notificationService.sendRecoveryNotification(monitor, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.consecutiveFailures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await monitorRepository.save(monitor);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`监控检测失败: ${error.message}`, error);
|
||||||
|
|
||||||
|
// 记录错误
|
||||||
|
result.status = MonitorStatus.DOWN;
|
||||||
|
result.error = `监控检测过程中发生错误: ${error.message}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resultRepository.save(result);
|
||||||
|
} catch (saveError: any) {
|
||||||
|
console.error(`保存监控结果失败: ${saveError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步执行 traceroute
|
||||||
|
private async executeTracerouteAsync(monitor: Monitor, resultId: number) {
|
||||||
|
try {
|
||||||
|
const resultRepository = AppDataSource.getRepository(MonitorResult);
|
||||||
|
|
||||||
|
// 根据操作系统选择命令
|
||||||
|
const cmd = process.platform === 'win32'
|
||||||
|
? `tracert -d -h 15 ${monitor.host}`
|
||||||
|
: `traceroute -n -m 15 ${monitor.host}`;
|
||||||
|
|
||||||
|
// 检查监控项是否仍处于激活状态
|
||||||
|
const monitorRepository = AppDataSource.getRepository(Monitor);
|
||||||
|
const currentMonitor = await monitorRepository.findOne({ where: { id: monitor.id } });
|
||||||
|
if (!currentMonitor || !currentMonitor.active) {
|
||||||
|
// 如果监控项已被删除或设为非激活,不执行 traceroute
|
||||||
|
await this.updateTracerouteResult(resultId, '监控项已停用,traceroute 已取消');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 spawn 而不是 exec,以便能够跟踪和终止进程
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
// 根据操作系统选择命令和参数
|
||||||
|
let command, args;
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
command = 'tracert';
|
||||||
|
args = ['-d', '-h', '15', monitor.host];
|
||||||
|
} else {
|
||||||
|
command = 'traceroute';
|
||||||
|
args = ['-n', '-m', '15', monitor.host];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建子进程
|
||||||
|
const childProcess = spawn(command, args);
|
||||||
|
|
||||||
|
// 将进程保存到 Map 中,以便能够在需要时终止它
|
||||||
|
this.tracerouteProcesses.set(monitor.id, { childProcess, resultId });
|
||||||
|
|
||||||
|
// 收集输出
|
||||||
|
childProcess.stdout.on('data', (data: Buffer) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.stderr.on('data', (data: Buffer) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 进程结束时的处理
|
||||||
|
childProcess.on('close', async (code: number) => {
|
||||||
|
// 从 Map 中移除进程
|
||||||
|
this.tracerouteProcesses.delete(monitor.id);
|
||||||
|
|
||||||
|
// 更新数据库中的结果
|
||||||
|
const result = await resultRepository.findOne({ where: { id: resultId } });
|
||||||
|
if (result) {
|
||||||
|
result.traceroute = output || `Traceroute 完成,但没有输出 (退出码: ${code})`;
|
||||||
|
await resultRepository.save(result);
|
||||||
|
console.log(`Traceroute 完成: ${monitor.host} (退出码: ${code})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置超时
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.tracerouteProcesses.has(monitor.id)) {
|
||||||
|
this.stopTraceroute(monitor.id);
|
||||||
|
console.log(`Traceroute 超时: ${monitor.host}`);
|
||||||
|
}
|
||||||
|
}, 120000); // 120 秒超时
|
||||||
|
} catch (err: any) {
|
||||||
|
// 处理 spawn 失败的情况
|
||||||
|
console.error(`Traceroute 启动失败: ${err.message}`);
|
||||||
|
|
||||||
|
// 更新数据库中的结果
|
||||||
|
const result = await resultRepository.findOne({ where: { id: resultId } });
|
||||||
|
if (result) {
|
||||||
|
result.traceroute = `无法执行 Traceroute: ${err.message}`;
|
||||||
|
await resultRepository.save(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Map 中移除进程
|
||||||
|
this.tracerouteProcesses.delete(monitor.id);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`Traceroute 异步执行失败: ${err.message}`);
|
||||||
|
// 从 Map 中移除进程
|
||||||
|
this.tracerouteProcesses.delete(monitor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const monitoringService = new MonitoringService();
|
|
@ -0,0 +1,185 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { Notification, NotificationType } from '../models/Notification';
|
||||||
|
import { Monitor } from '../models/Monitor';
|
||||||
|
import { MonitorResult } from '../models/MonitorResult';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { AppDataSource } from '../index';
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
// 发送服务宕机通知
|
||||||
|
async sendDownNotification(monitor: Monitor, result: MonitorResult) {
|
||||||
|
try {
|
||||||
|
const notificationRepository = AppDataSource.getRepository(Notification);
|
||||||
|
const notifications = await notificationRepository.find({ where: { active: true } });
|
||||||
|
|
||||||
|
const title = `🔴 服务宕机: ${monitor.name}`;
|
||||||
|
const content = this.generateDownNotificationContent(monitor, result);
|
||||||
|
|
||||||
|
for (const notification of notifications) {
|
||||||
|
await this.sendNotification(notification, title, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`已发送服务宕机通知: ${monitor.name}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('发送服务宕机通知失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送服务恢复通知
|
||||||
|
async sendRecoveryNotification(monitor: Monitor, result: MonitorResult) {
|
||||||
|
try {
|
||||||
|
const notificationRepository = AppDataSource.getRepository(Notification);
|
||||||
|
const notifications = await notificationRepository.find({ where: { active: true } });
|
||||||
|
|
||||||
|
const title = `🟢 服务恢复: ${monitor.name}`;
|
||||||
|
const content = this.generateRecoveryNotificationContent(monitor, result);
|
||||||
|
|
||||||
|
for (const notification of notifications) {
|
||||||
|
await this.sendNotification(notification, title, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`已发送服务恢复通知: ${monitor.name}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('发送服务恢复通知失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送测试通知
|
||||||
|
async sendTestNotification(notification: Notification) {
|
||||||
|
try {
|
||||||
|
const title = '📝 测试通知';
|
||||||
|
const content = '这是一条测试通知,如果您收到此消息,说明通知配置正确。';
|
||||||
|
|
||||||
|
const result = await this.sendNotification(notification, title, content);
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('发送测试通知失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送通知
|
||||||
|
private async sendNotification(notification: Notification, title: string, content: string) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(notification.config);
|
||||||
|
|
||||||
|
if (notification.type === NotificationType.FEISHU) {
|
||||||
|
return await this.sendFeishuNotification(config.webhook, title, content);
|
||||||
|
} else if (notification.type === NotificationType.EMAIL) {
|
||||||
|
return await this.sendEmailNotification(config, title, content);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`发送通知失败 (${notification.name}):`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送飞书通知
|
||||||
|
private async sendFeishuNotification(webhook: string, title: string, content: string) {
|
||||||
|
try {
|
||||||
|
const message = {
|
||||||
|
msg_type: 'post',
|
||||||
|
content: {
|
||||||
|
post: {
|
||||||
|
zh_cn: {
|
||||||
|
title: title,
|
||||||
|
content: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
tag: 'text',
|
||||||
|
text: content
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post(webhook, message);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('发送飞书通知失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送邮件通知
|
||||||
|
private async sendEmailNotification(config: any, title: string, content: string) {
|
||||||
|
try {
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
secure: config.secure,
|
||||||
|
auth: {
|
||||||
|
user: config.user,
|
||||||
|
pass: config.pass
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: config.from,
|
||||||
|
to: config.to,
|
||||||
|
subject: title,
|
||||||
|
text: content,
|
||||||
|
html: content.replace(/\n/g, '<br>')
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = await transporter.sendMail(mailOptions);
|
||||||
|
return info;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('发送邮件通知失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成服务宕机通知内容
|
||||||
|
private generateDownNotificationContent(monitor: Monitor, result: MonitorResult): string {
|
||||||
|
let content = `服务 ${monitor.name} (${monitor.host}:${monitor.port}) 已宕机\n\n`;
|
||||||
|
content += `- 监控类型: ${monitor.type}\n`;
|
||||||
|
content += `- 宕机时间: ${result.createdAt.toLocaleString()}\n`;
|
||||||
|
content += `- 连续失败次数: ${monitor.consecutiveFailures}\n`;
|
||||||
|
|
||||||
|
if (result.responseTime) {
|
||||||
|
content += `- 响应时间: ${result.responseTime}ms\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.statusCode) {
|
||||||
|
content += `- HTTP状态码: ${result.statusCode}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
content += `- 错误信息: ${result.error}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.dnsResolution) {
|
||||||
|
content += `\nDNS解析结果:\n${result.dnsResolution}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.traceroute) {
|
||||||
|
content += `\nTraceroute结果:\n${result.traceroute}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成服务恢复通知内容
|
||||||
|
private generateRecoveryNotificationContent(monitor: Monitor, result: MonitorResult): string {
|
||||||
|
let content = `服务 ${monitor.name} (${monitor.host}:${monitor.port}) 已恢复\n\n`;
|
||||||
|
content += `- 监控类型: ${monitor.type}\n`;
|
||||||
|
content += `- 恢复时间: ${result.createdAt.toLocaleString()}\n`;
|
||||||
|
|
||||||
|
if (result.responseTime) {
|
||||||
|
content += `- 响应时间: ${result.responseTime}ms\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.statusCode) {
|
||||||
|
content += `- HTTP状态码: ${result.statusCode}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationService = new NotificationService();
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# 后端服务
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pingping-backend
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
ports:
|
||||||
|
- "2070:2070"
|
||||||
|
networks:
|
||||||
|
- pingping-network
|
||||||
|
|
||||||
|
# 前端服务
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pingping-frontend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- pingping-network
|
||||||
|
|
||||||
|
# 网络配置
|
||||||
|
networks:
|
||||||
|
pingping-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
# 数据卷配置
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
driver: local
|
|
@ -0,0 +1,27 @@
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'pingping-backend',
|
||||||
|
script: 'backend/dist/index.js',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pingping-frontend',
|
||||||
|
script: 'pnpm',
|
||||||
|
args: '--filter frontend preview -- --port 3000',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
|
@ -0,0 +1,25 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2021: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
'plugin:vue/vue3-recommended',
|
||||||
|
'eslint:recommended',
|
||||||
|
'@vue/typescript/recommended',
|
||||||
|
'prettier',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2021,
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"endOfLine": "auto"
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
FROM node:slim-alpine AS build
|
||||||
|
|
||||||
|
# 创建工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# 复制 package.json 和 pnpm-lock.yaml
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建前端项目
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# 使用 nginx 作为生产环境的 web 服务器
|
||||||
|
FROM nginx:23-alpine
|
||||||
|
|
||||||
|
# 复制构建好的文件到 nginx 目录
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 复制 nginx 配置文件
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# 启动 nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + Vue + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,20 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# 前端文件
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 后端 API 代理
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:2070/api;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"element-plus": "^2.9.7",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-echarts": "^7.0.3",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.29.1",
|
||||||
|
"@typescript-eslint/parser": "^8.29.1",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^4.1.2",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"eslint": "^9.24.0",
|
||||||
|
"eslint-config-prettier": "^10.1.2",
|
||||||
|
"eslint-plugin-prettier": "^5.2.6",
|
||||||
|
"eslint-plugin-vue": "^10.0.0",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.2.0",
|
||||||
|
"vue-tsc": "^2.2.4"
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -0,0 +1,245 @@
|
||||||
|
<template>
|
||||||
|
<div class="app-container" :class="{ 'dark-mode': isDarkMode }">
|
||||||
|
<el-container>
|
||||||
|
<el-aside width="220px" class="sidebar">
|
||||||
|
<div class="logo">
|
||||||
|
<h2>Pingping</h2>
|
||||||
|
</div>
|
||||||
|
<el-menu
|
||||||
|
router
|
||||||
|
default-active="/"
|
||||||
|
class="sidebar-menu"
|
||||||
|
:background-color="isDarkMode ? '#1e1e1e' : '#ffffff'"
|
||||||
|
:text-color="isDarkMode ? '#e5eaf3' : '#2c3e50'"
|
||||||
|
:active-text-color="isDarkMode ? '#409EFF' : '#409EFF'"
|
||||||
|
>
|
||||||
|
<el-menu-item index="/">
|
||||||
|
<el-icon><el-icon-odometer /></el-icon>
|
||||||
|
<span>仪表盘</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/monitors">
|
||||||
|
<el-icon><el-icon-monitor /></el-icon>
|
||||||
|
<span>监控项</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/notifications">
|
||||||
|
<el-icon><el-icon-bell /></el-icon>
|
||||||
|
<span>通知设置</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/settings">
|
||||||
|
<el-icon><el-icon-setting /></el-icon>
|
||||||
|
<span>系统设置</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
<div class="theme-toggle">
|
||||||
|
<el-tooltip
|
||||||
|
:content="isDarkMode ? '切换到亮色模式' : '切换到暗色模式'"
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
size="large"
|
||||||
|
@click="toggleDarkMode"
|
||||||
|
>
|
||||||
|
<el-icon>
|
||||||
|
<component :is="isDarkMode ? Sunny : Moon" />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</el-aside>
|
||||||
|
<el-container class="main-container">
|
||||||
|
<el-header height="60px">
|
||||||
|
<div class="header-content">
|
||||||
|
<h2>{{ currentRoute }}</h2>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
<el-main>
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
ElContainer,
|
||||||
|
ElHeader,
|
||||||
|
ElAside,
|
||||||
|
ElMain,
|
||||||
|
ElMenu,
|
||||||
|
ElMenuItem,
|
||||||
|
ElIcon,
|
||||||
|
ElButton,
|
||||||
|
ElTooltip,
|
||||||
|
} from 'element-plus';
|
||||||
|
import {
|
||||||
|
Odometer as ElIconOdometer,
|
||||||
|
Monitor as ElIconMonitor,
|
||||||
|
Bell as ElIconBell,
|
||||||
|
Setting as ElIconSetting,
|
||||||
|
Moon,
|
||||||
|
Sunny,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const isDarkMode = ref(localStorage.getItem('theme') === 'dark');
|
||||||
|
|
||||||
|
// 切换暗黑模式
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
isDarkMode.value = !isDarkMode.value;
|
||||||
|
localStorage.setItem('theme', isDarkMode.value ? 'dark' : 'light');
|
||||||
|
|
||||||
|
// 切换 Element Plus 的暗黑模式
|
||||||
|
if (isDarkMode.value) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
document.body.setAttribute('data-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.body.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化暗黑模式
|
||||||
|
watch(isDarkMode, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
document.body.setAttribute('data-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.body.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const currentRoute = computed(() => {
|
||||||
|
switch (route.path) {
|
||||||
|
case '/':
|
||||||
|
return '仪表盘';
|
||||||
|
case '/monitors':
|
||||||
|
return '监控项';
|
||||||
|
case '/notifications':
|
||||||
|
return '通知设置';
|
||||||
|
case '/settings':
|
||||||
|
return '系统设置';
|
||||||
|
default:
|
||||||
|
if (route.path.startsWith('/monitor/')) {
|
||||||
|
return '监控详情';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #409EFF;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-bottom: 1px solid #f1f1f1;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 10;
|
||||||
|
transition: all 0.3s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
border-right: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
width: calc(100% - 220px);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-header {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #e6e6e6;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
padding: 0 24px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-main {
|
||||||
|
padding: 0;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #f8f9fc;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-aside {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #2c3e50;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗黑模式样式 */
|
||||||
|
.dark-mode .logo {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #409EFF;
|
||||||
|
border-bottom: 1px solid #2c2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .el-aside {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #e5eaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .el-header {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border-bottom: 1px solid #2c2c2c;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .el-main {
|
||||||
|
background-color: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 64px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
width: calc(100% - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
left: 32px;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,235 @@
|
||||||
|
<template>
|
||||||
|
<div class="app-container" :class="{ 'dark-mode': isDarkMode }">
|
||||||
|
<el-container>
|
||||||
|
<el-aside width="220px" class="sidebar">
|
||||||
|
<div class="logo">
|
||||||
|
<h2>Pingping</h2>
|
||||||
|
</div>
|
||||||
|
<el-menu
|
||||||
|
router
|
||||||
|
default-active="/"
|
||||||
|
class="sidebar-menu"
|
||||||
|
:background-color="isDarkMode ? '#1e1e1e' : '#ffffff'"
|
||||||
|
:text-color="isDarkMode ? '#e5eaf3' : '#2c3e50'"
|
||||||
|
:active-text-color="isDarkMode ? '#409EFF' : '#409EFF'"
|
||||||
|
>
|
||||||
|
<el-menu-item index="/">
|
||||||
|
<el-icon><el-icon-odometer /></el-icon>
|
||||||
|
<span>仪表盘</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/monitors">
|
||||||
|
<el-icon><el-icon-monitor /></el-icon>
|
||||||
|
<span>监控项</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/notifications">
|
||||||
|
<el-icon><el-icon-bell /></el-icon>
|
||||||
|
<span>通知设置</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
<div class="theme-toggle">
|
||||||
|
<el-tooltip
|
||||||
|
:content="isDarkMode ? '切换到亮色模式' : '切换到暗色模式'"
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
size="large"
|
||||||
|
@click="toggleDarkMode"
|
||||||
|
:icon="isDarkMode ? 'Sunny' : 'Moon'"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</el-aside>
|
||||||
|
<el-container class="main-container">
|
||||||
|
<el-header height="60px">
|
||||||
|
<div class="header-content">
|
||||||
|
<h2>{{ currentRoute }}</h2>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
<el-main>
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
ElContainer,
|
||||||
|
ElHeader,
|
||||||
|
ElAside,
|
||||||
|
ElMain,
|
||||||
|
ElMenu,
|
||||||
|
ElMenuItem,
|
||||||
|
ElIcon,
|
||||||
|
ElButton,
|
||||||
|
ElTooltip,
|
||||||
|
} from 'element-plus';
|
||||||
|
import {
|
||||||
|
Odometer as ElIconOdometer,
|
||||||
|
Monitor as ElIconMonitor,
|
||||||
|
Bell as ElIconBell,
|
||||||
|
Moon,
|
||||||
|
Sunny,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const isDarkMode = ref(localStorage.getItem('theme') === 'dark');
|
||||||
|
|
||||||
|
// 切换暗黑模式
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
isDarkMode.value = !isDarkMode.value;
|
||||||
|
localStorage.setItem('theme', isDarkMode.value ? 'dark' : 'light');
|
||||||
|
|
||||||
|
// 切换 Element Plus 的暗黑模式
|
||||||
|
if (isDarkMode.value) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
document.body.setAttribute('data-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.body.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化暗黑模式
|
||||||
|
watch(isDarkMode, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
document.body.setAttribute('data-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.body.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const currentRoute = computed(() => {
|
||||||
|
switch (route.path) {
|
||||||
|
case '/':
|
||||||
|
return '仪表盘';
|
||||||
|
case '/monitors':
|
||||||
|
return '监控项';
|
||||||
|
case '/notifications':
|
||||||
|
return '通知设置';
|
||||||
|
default:
|
||||||
|
if (route.path.startsWith('/monitor/')) {
|
||||||
|
return '监控详情';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #409EFF;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-bottom: 1px solid #f1f1f1;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 10;
|
||||||
|
transition: all 0.3s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
border-right: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
width: calc(100% - 220px);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-header {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #e6e6e6;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
padding: 0 24px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-main {
|
||||||
|
padding: 0;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #f8f9fc;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-aside {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #2c3e50;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗黑模式样式 */
|
||||||
|
.dark-mode .logo {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #409EFF;
|
||||||
|
border-bottom: 1px solid #2c2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .el-aside {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #e5eaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .el-header {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border-bottom: 1px solid #2c2c2c;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .el-main {
|
||||||
|
background-color: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 64px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
width: calc(100% - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
left: 32px;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
After Width: | Height: | Size: 496 B |
|
@ -0,0 +1,41 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{ msg: string }>()
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>{{ msg }}</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<button type="button" @click="count++">count is {{ count }}</button>
|
||||||
|
<p>
|
||||||
|
Edit
|
||||||
|
<code>components/HelloWorld.vue</code> to test HMR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Check out
|
||||||
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||||
|
>create-vue</a
|
||||||
|
>, the official Vue + Vite starter
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Learn more about IDE Support for Vue in the
|
||||||
|
<a
|
||||||
|
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||||
|
target="_blank"
|
||||||
|
>Vue Docs Scaling up Guide</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, {
|
||||||
|
locale: zhCn,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.mount('#app')
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('../views/Dashboard.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/monitors',
|
||||||
|
name: 'Monitors',
|
||||||
|
component: () => import('../views/Monitors.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/monitor/:id',
|
||||||
|
name: 'MonitorDetail',
|
||||||
|
component: () => import('../views/MonitorDetail.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/notifications',
|
||||||
|
name: 'Notifications',
|
||||||
|
component: () => import('../views/Notifications.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: () => import('../views/Settings.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,168 @@
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
color-scheme: light dark;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
|
||||||
|
--background-color: #f8f9fc;
|
||||||
|
--text-color: #2c3e50;
|
||||||
|
--border-color: #e6e6e6;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.1);
|
||||||
|
--hover-color: #f5f7fa;
|
||||||
|
--active-color: #409EFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
--background-color: #141414;
|
||||||
|
--text-color: #e5eaf3;
|
||||||
|
--border-color: #2c2c2c;
|
||||||
|
--card-bg: #1e1e1e;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||||
|
--hover-color: #18222c;
|
||||||
|
--active-color: #409EFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--active-color);
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 12px 0 var(--shadow-color);
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Element Plus 暗黑模式兼容 */
|
||||||
|
html.dark .el-card {
|
||||||
|
--el-card-bg-color: var(--card-bg);
|
||||||
|
--el-border-color-light: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-table {
|
||||||
|
--el-table-bg-color: var(--card-bg);
|
||||||
|
--el-table-tr-bg-color: var(--card-bg);
|
||||||
|
--el-table-header-bg-color: var(--card-bg);
|
||||||
|
--el-table-border-color: var(--border-color);
|
||||||
|
--el-table-text-color: var(--text-color);
|
||||||
|
--el-fill-color-lighter: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-pagination {
|
||||||
|
--el-pagination-bg-color: var(--card-bg);
|
||||||
|
--el-pagination-text-color: var(--text-color);
|
||||||
|
--el-pagination-button-color: var(--text-color);
|
||||||
|
--el-pagination-border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 对话框暗黑模式 */
|
||||||
|
html.dark .el-dialog {
|
||||||
|
--el-dialog-bg-color: var(--card-bg);
|
||||||
|
--el-dialog-text-color: var(--text-color);
|
||||||
|
--el-dialog-border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单暗黑模式 */
|
||||||
|
html.dark .el-form-item__label {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-input__wrapper,
|
||||||
|
html.dark .el-textarea__wrapper {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
box-shadow: 0 0 0 1px var(--border-color) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-input__inner,
|
||||||
|
html.dark .el-textarea__inner {
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-select-dropdown {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-select-dropdown__item {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-select-dropdown__item.hover,
|
||||||
|
html.dark .el-select-dropdown__item:hover {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-collapse-item__header,
|
||||||
|
html.dark .el-collapse-item__content {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark pre {
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
border-color: #3e3e3e;
|
||||||
|
color: #e5eaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #2c3e50;
|
||||||
|
background-color: #f8f9fc;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const res = response.data;
|
||||||
|
if (res.success === false) {
|
||||||
|
ElMessage.error(res.message || '请求失败');
|
||||||
|
return Promise.reject(new Error(res.message || '请求失败'));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
const message = error.response?.data?.message || error.message || '请求失败';
|
||||||
|
ElMessage.error(message);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
|
@ -0,0 +1,263 @@
|
||||||
|
<template>
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card class="status-card">
|
||||||
|
<div class="status-card-content">
|
||||||
|
<div class="status-icon success">
|
||||||
|
<el-icon><el-icon-check /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="status-info">
|
||||||
|
<h2>{{ upCount }}</h2>
|
||||||
|
<p>运行正常</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card class="status-card">
|
||||||
|
<div class="status-card-content">
|
||||||
|
<div class="status-icon danger">
|
||||||
|
<el-icon><el-icon-close /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="status-info">
|
||||||
|
<h2>{{ downCount }}</h2>
|
||||||
|
<p>服务宕机</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card class="status-card">
|
||||||
|
<div class="status-card-content">
|
||||||
|
<div class="status-icon warning">
|
||||||
|
<el-icon><el-icon-warning /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="status-info">
|
||||||
|
<h2>{{ pendingCount }}</h2>
|
||||||
|
<p>等待检测</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-card class="monitor-list-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>监控状态</span>
|
||||||
|
<el-button type="primary" @click="refreshData">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table :data="monitors" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" />
|
||||||
|
<el-table-column prop="type" label="类型" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag size="small" :type="scope.row.type === 'http' ? 'success' : 'warning'">
|
||||||
|
{{ scope.row.type.toUpperCase() }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="host" label="主机" min-width="150" />
|
||||||
|
<el-table-column prop="port" label="端口" width="100" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag
|
||||||
|
size="small"
|
||||||
|
:type="
|
||||||
|
scope.row.status === 'up'
|
||||||
|
? 'success'
|
||||||
|
: scope.row.status === 'down'
|
||||||
|
? 'danger'
|
||||||
|
: 'info'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ statusText(scope.row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="lastCheckTime" label="最后检测时间" width="180">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ scope.row.lastCheckTime ? new Date(scope.row.lastCheckTime).toLocaleString() : '未检测' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
@click="goToDetail(scope.row.id)"
|
||||||
|
text
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
@click="checkNow(scope.row.id)"
|
||||||
|
text
|
||||||
|
>
|
||||||
|
立即检测
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, onUnmounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { Check as ElIconCheck, Close as ElIconClose, Warning as ElIconWarning } from '@element-plus/icons-vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
interface Monitor {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
status: string;
|
||||||
|
lastCheckTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const monitors = ref<Monitor[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const upCount = computed(() => monitors.value.filter(m => m.status === 'up').length);
|
||||||
|
const downCount = computed(() => monitors.value.filter(m => m.status === 'down').length);
|
||||||
|
const pendingCount = computed(() => monitors.value.filter(m => m.status === 'pending').length);
|
||||||
|
|
||||||
|
const statusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return '正常';
|
||||||
|
case 'down':
|
||||||
|
return '宕机';
|
||||||
|
case 'pending':
|
||||||
|
return '等待检测';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/monitoring');
|
||||||
|
monitors.value = response.data.data;
|
||||||
|
console.log('获取监控数据:', monitors.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取监控数据失败:', error);
|
||||||
|
ElMessage.error('获取监控数据失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = () => {
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToDetail = (id: number) => {
|
||||||
|
router.push(`/monitor/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkNow = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/monitoring/${id}/check`);
|
||||||
|
ElMessage.success('已触发检测');
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchData();
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('触发检测失败:', error);
|
||||||
|
ElMessage.error('触发检测失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
// 每60秒自动刷新一次
|
||||||
|
const interval = setInterval(fetchData, 60000);
|
||||||
|
|
||||||
|
// 组件卸载时清除定时器
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon.success {
|
||||||
|
background-color: #67c23a;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon.danger {
|
||||||
|
background-color: #f56c6c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon.warning {
|
||||||
|
background-color: #e6a23c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon .el-icon {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-list-card {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,371 @@
|
||||||
|
<template>
|
||||||
|
<div class="monitors-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>监控项列表</h2>
|
||||||
|
<el-button type="primary" @click="showAddDialog">添加监控项</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="monitors" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" />
|
||||||
|
<el-table-column prop="type" label="类型" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag size="small" :type="getTypeTagType(scope.row.type)">
|
||||||
|
{{ scope.row.type.toUpperCase() }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="host" label="主机" min-width="150" />
|
||||||
|
<el-table-column prop="port" label="端口" width="100" />
|
||||||
|
<el-table-column prop="interval" label="检测间隔" width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ scope.row.interval }} 秒
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag
|
||||||
|
size="small"
|
||||||
|
:type="
|
||||||
|
scope.row.status === 'up'
|
||||||
|
? 'success'
|
||||||
|
: scope.row.status === 'down'
|
||||||
|
? 'danger'
|
||||||
|
: 'info'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ statusText(scope.row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="active" label="是否激活" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-switch
|
||||||
|
v-model="scope.row.active"
|
||||||
|
@change="toggleActive(scope.row)"
|
||||||
|
:active-value="true"
|
||||||
|
:inactive-value="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-button size="small" type="primary" @click="goToDetail(scope.row.id)" text>
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="editMonitor(scope.row)" text>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="deleteMonitor(scope.row.id)" text>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 添加/编辑监控项对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="isEdit ? '编辑监控项' : '添加监控项'"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
:model="monitorForm"
|
||||||
|
:rules="rules"
|
||||||
|
ref="monitorFormRef"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="monitorForm.name" placeholder="请输入监控项名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型" prop="type">
|
||||||
|
<el-select v-model="monitorForm.type" placeholder="请选择监控类型" style="width: 100%">
|
||||||
|
<el-option label="HTTP" value="http" />
|
||||||
|
<el-option label="HTTPS" value="https" />
|
||||||
|
<el-option label="TCP" value="tcp" />
|
||||||
|
<el-option label="PING" value="ping" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="主机" prop="host">
|
||||||
|
<el-input v-model="monitorForm.host" placeholder="请输入主机名或IP地址" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="端口" prop="port" v-if="monitorForm.type !== 'ping'">
|
||||||
|
<el-input-number v-model="monitorForm.port" :min="1" :max="65535" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="路径" v-if="monitorForm.type === 'http' || monitorForm.type === 'https'">
|
||||||
|
<el-input v-model="monitorForm.path" placeholder="请输入URL路径,如 /api/status" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="期望状态码" v-if="monitorForm.type === 'http' || monitorForm.type === 'https'">
|
||||||
|
<el-input-number v-model="monitorForm.expectedStatus" :min="100" :max="599" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="期望内容" v-if="monitorForm.type === 'http' || monitorForm.type === 'https'">
|
||||||
|
<el-input
|
||||||
|
v-model="monitorForm.expectedContent"
|
||||||
|
placeholder="请输入期望的响应内容包含的字符串"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="超时时间" prop="timeout">
|
||||||
|
<el-input-number
|
||||||
|
v-model="monitorForm.timeout"
|
||||||
|
:min="1000"
|
||||||
|
:max="60000"
|
||||||
|
:step="1000"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">毫秒</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="检测间隔" prop="interval">
|
||||||
|
<el-input-number
|
||||||
|
v-model="monitorForm.interval"
|
||||||
|
:min="10"
|
||||||
|
:max="86400"
|
||||||
|
:step="10"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">秒</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="是否激活" prop="active">
|
||||||
|
<el-switch v-model="monitorForm.active" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
interface Monitor {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
expectedStatus?: number;
|
||||||
|
expectedContent?: string;
|
||||||
|
timeout: number;
|
||||||
|
interval: number;
|
||||||
|
status: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const monitors = ref<Monitor[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const isEdit = ref(false);
|
||||||
|
const monitorFormRef = ref();
|
||||||
|
|
||||||
|
const monitorForm = reactive({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
type: 'http',
|
||||||
|
host: '',
|
||||||
|
port: 80,
|
||||||
|
path: '/',
|
||||||
|
expectedStatus: 200,
|
||||||
|
expectedContent: '',
|
||||||
|
timeout: 5000,
|
||||||
|
interval: 60,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根据监控类型设置默认端口
|
||||||
|
watch(() => monitorForm.type, (newType) => {
|
||||||
|
if (newType === 'http') {
|
||||||
|
monitorForm.port = 80;
|
||||||
|
} else if (newType === 'https') {
|
||||||
|
monitorForm.port = 443;
|
||||||
|
} else if (newType === 'ping') {
|
||||||
|
// PING 不需要端口
|
||||||
|
monitorForm.port = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根据监控类型获取标签样式
|
||||||
|
const getTypeTagType = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'http':
|
||||||
|
return 'success';
|
||||||
|
case 'https':
|
||||||
|
return 'primary';
|
||||||
|
case 'tcp':
|
||||||
|
return 'warning';
|
||||||
|
case 'ping':
|
||||||
|
return 'info';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: '请输入监控项名称', trigger: 'blur' }],
|
||||||
|
type: [{ required: true, message: '请选择监控类型', trigger: 'change' }],
|
||||||
|
host: [{ required: true, message: '请输入主机名或IP地址', trigger: 'blur' }],
|
||||||
|
port: [{ required: true, message: '请输入端口号', trigger: 'blur' }],
|
||||||
|
timeout: [{ required: true, message: '请输入超时时间', trigger: 'blur' }],
|
||||||
|
interval: [{ required: true, message: '请输入检测间隔', trigger: 'blur' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return '正常';
|
||||||
|
case 'down':
|
||||||
|
return '宕机';
|
||||||
|
case 'pending':
|
||||||
|
return '等待检测';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/monitoring');
|
||||||
|
monitors.value = response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取监控数据失败:', error);
|
||||||
|
ElMessage.error('获取监控数据失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
monitorForm.id = 0;
|
||||||
|
monitorForm.name = '';
|
||||||
|
monitorForm.type = 'http';
|
||||||
|
monitorForm.host = '';
|
||||||
|
monitorForm.port = 80;
|
||||||
|
monitorForm.path = '/';
|
||||||
|
monitorForm.expectedStatus = 200;
|
||||||
|
monitorForm.expectedContent = '';
|
||||||
|
monitorForm.timeout = 5000;
|
||||||
|
monitorForm.interval = 60;
|
||||||
|
monitorForm.active = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showAddDialog = () => {
|
||||||
|
resetForm();
|
||||||
|
isEdit.value = false;
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editMonitor = (monitor: Monitor) => {
|
||||||
|
isEdit.value = true;
|
||||||
|
monitorForm.id = monitor.id;
|
||||||
|
monitorForm.name = monitor.name;
|
||||||
|
monitorForm.type = monitor.type;
|
||||||
|
monitorForm.host = monitor.host;
|
||||||
|
monitorForm.port = monitor.port;
|
||||||
|
monitorForm.path = monitor.path || '/';
|
||||||
|
monitorForm.expectedStatus = monitor.expectedStatus || 200;
|
||||||
|
monitorForm.expectedContent = monitor.expectedContent || '';
|
||||||
|
monitorForm.timeout = monitor.timeout;
|
||||||
|
monitorForm.interval = monitor.interval;
|
||||||
|
monitorForm.active = monitor.active;
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!monitorFormRef.value) return;
|
||||||
|
|
||||||
|
await monitorFormRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await axios.put(`/api/monitoring/${monitorForm.id}`, monitorForm);
|
||||||
|
ElMessage.success('更新监控项成功');
|
||||||
|
} else {
|
||||||
|
await axios.post('/api/monitoring', monitorForm);
|
||||||
|
ElMessage.success('添加监控项成功');
|
||||||
|
}
|
||||||
|
dialogVisible.value = false;
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存监控项失败:', error);
|
||||||
|
ElMessage.error('保存监控项失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMonitor = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该监控项吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
|
||||||
|
await axios.delete(`/api/monitoring/${id}`);
|
||||||
|
ElMessage.success('删除监控项成功');
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('删除监控项失败:', error);
|
||||||
|
ElMessage.error('删除监控项失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleActive = async (monitor: Monitor) => {
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/monitoring/${monitor.id}`, {
|
||||||
|
active: monitor.active,
|
||||||
|
});
|
||||||
|
ElMessage.success(`${monitor.active ? '启用' : '禁用'}监控项成功`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新监控项状态失败:', error);
|
||||||
|
ElMessage.error('更新监控项状态失败');
|
||||||
|
// 恢复原状态
|
||||||
|
monitor.active = !monitor.active;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToDetail = (id: number) => {
|
||||||
|
router.push(`/monitor/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.monitors-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,360 @@
|
||||||
|
<template>
|
||||||
|
<div class="notifications-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>通知设置</h2>
|
||||||
|
<el-button type="primary" @click="showAddDialog">添加通知</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="notifications" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" />
|
||||||
|
<el-table-column prop="type" label="类型" width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag size="small" :type="scope.row.type === 'feishu' ? 'success' : 'primary'">
|
||||||
|
{{ typeText(scope.row.type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="config" label="配置" min-width="200">
|
||||||
|
<template #default="scope">
|
||||||
|
<div v-if="scope.row.type === 'feishu'">
|
||||||
|
Webhook: {{ maskUrl(getConfigValue(scope.row.config, 'webhook')) }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="scope.row.type === 'email'">
|
||||||
|
收件人: {{ getConfigValue(scope.row.config, 'to') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="active" label="是否激活" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-switch
|
||||||
|
v-model="scope.row.active"
|
||||||
|
@change="toggleActive(scope.row)"
|
||||||
|
:active-value="true"
|
||||||
|
:inactive-value="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button size="small" type="primary" @click="editNotification(scope.row)" text>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="success" @click="testNotification(scope.row.id)" text>
|
||||||
|
测试
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="deleteNotification(scope.row.id)" text>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 添加/编辑通知对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="isEdit ? '编辑通知' : '添加通知'"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
:model="notificationForm"
|
||||||
|
:rules="rules"
|
||||||
|
ref="notificationFormRef"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="notificationForm.name" placeholder="请输入通知名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型" prop="type">
|
||||||
|
<el-select v-model="notificationForm.type" placeholder="请选择通知类型" style="width: 100%">
|
||||||
|
<el-option label="飞书" value="feishu" />
|
||||||
|
<el-option label="邮件" value="email" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 飞书配置 -->
|
||||||
|
<template v-if="notificationForm.type === 'feishu'">
|
||||||
|
<el-form-item label="Webhook" prop="webhook">
|
||||||
|
<el-input
|
||||||
|
v-model="notificationForm.webhook"
|
||||||
|
placeholder="请输入飞书 Webhook URL"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 邮件配置 -->
|
||||||
|
<template v-if="notificationForm.type === 'email'">
|
||||||
|
<el-form-item label="收件人" prop="to">
|
||||||
|
<el-input
|
||||||
|
v-model="notificationForm.to"
|
||||||
|
placeholder="请输入收件人邮箱,多个邮箱用逗号分隔"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form-item label="是否激活" prop="active">
|
||||||
|
<el-switch v-model="notificationForm.active" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
config: string;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = ref<Notification[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const isEdit = ref(false);
|
||||||
|
const notificationFormRef = ref();
|
||||||
|
|
||||||
|
const notificationForm = reactive({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
type: 'feishu',
|
||||||
|
webhook: '',
|
||||||
|
to: '',
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: '请输入通知名称', trigger: 'blur' }],
|
||||||
|
type: [{ required: true, message: '请选择通知类型', trigger: 'change' }],
|
||||||
|
webhook: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入飞书 Webhook URL',
|
||||||
|
trigger: 'blur',
|
||||||
|
validator: (_: any, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (notificationForm.type === 'feishu' && !value) {
|
||||||
|
callback(new Error('请输入飞书 Webhook URL'));
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
to: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入收件人邮箱',
|
||||||
|
trigger: 'blur',
|
||||||
|
validator: (_: any, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (notificationForm.type === 'email' && !value) {
|
||||||
|
callback(new Error('请输入收件人邮箱'));
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeText = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'feishu':
|
||||||
|
return '飞书';
|
||||||
|
case 'email':
|
||||||
|
return '邮件';
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfigValue = (configStr: string, key: string) => {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(configStr);
|
||||||
|
return config[key] || '';
|
||||||
|
} catch (error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maskUrl = (url: string) => {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.length <= 20) return url;
|
||||||
|
return url.substring(0, 10) + '...' + url.substring(url.length - 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/notification');
|
||||||
|
notifications.value = response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取通知数据失败:', error);
|
||||||
|
ElMessage.error('获取通知数据失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
notificationForm.id = 0;
|
||||||
|
notificationForm.name = '';
|
||||||
|
notificationForm.type = 'feishu';
|
||||||
|
notificationForm.webhook = '';
|
||||||
|
notificationForm.to = '';
|
||||||
|
notificationForm.active = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showAddDialog = () => {
|
||||||
|
resetForm();
|
||||||
|
isEdit.value = false;
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editNotification = (notification: Notification) => {
|
||||||
|
isEdit.value = true;
|
||||||
|
notificationForm.id = notification.id;
|
||||||
|
notificationForm.name = notification.name;
|
||||||
|
notificationForm.type = notification.type;
|
||||||
|
notificationForm.active = notification.active;
|
||||||
|
|
||||||
|
// 解析配置
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(notification.config);
|
||||||
|
if (notification.type === 'feishu') {
|
||||||
|
notificationForm.webhook = config.webhook || '';
|
||||||
|
} else if (notification.type === 'email') {
|
||||||
|
notificationForm.to = config.to || '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析配置失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!notificationFormRef.value) return;
|
||||||
|
|
||||||
|
await notificationFormRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
// 构建配置对象
|
||||||
|
let config: any = {};
|
||||||
|
if (notificationForm.type === 'feishu') {
|
||||||
|
config = { webhook: notificationForm.webhook };
|
||||||
|
} else if (notificationForm.type === 'email') {
|
||||||
|
config = { to: notificationForm.to };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: notificationForm.name,
|
||||||
|
type: notificationForm.type,
|
||||||
|
config: JSON.stringify(config),
|
||||||
|
active: notificationForm.active,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
await axios.put(`/api/notification/${notificationForm.id}`, data);
|
||||||
|
ElMessage.success('更新通知配置成功');
|
||||||
|
} else {
|
||||||
|
await axios.post('/api/notification', data);
|
||||||
|
ElMessage.success('添加通知配置成功');
|
||||||
|
}
|
||||||
|
dialogVisible.value = false;
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存通知配置失败:', error);
|
||||||
|
ElMessage.error('保存通知配置失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteNotification = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该通知配置吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
|
||||||
|
await axios.delete(`/api/notification/${id}`);
|
||||||
|
ElMessage.success('删除通知配置成功');
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('删除通知配置失败:', error);
|
||||||
|
ElMessage.error('删除通知配置失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleActive = async (notification: Notification) => {
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/notification/${notification.id}`, {
|
||||||
|
active: notification.active,
|
||||||
|
});
|
||||||
|
ElMessage.success(`${notification.active ? '启用' : '禁用'}通知配置成功`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新通知配置状态失败:', error);
|
||||||
|
ElMessage.error('更新通知配置状态失败');
|
||||||
|
// 恢复原状态
|
||||||
|
notification.active = !notification.active;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testNotification = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/notification/${id}/test`);
|
||||||
|
ElMessage.success('测试通知已发送');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送测试通知失败:', error);
|
||||||
|
ElMessage.error('发送测试通知失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notifications-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗黑模式样式适配 */
|
||||||
|
:root[data-theme="dark"] .notifications-container,
|
||||||
|
html.dark .notifications-container {
|
||||||
|
background-color: #141414;
|
||||||
|
color: #e5eaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .page-header h2,
|
||||||
|
html.dark .page-header h2 {
|
||||||
|
color: #e5eaf3;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,673 @@
|
||||||
|
<template>
|
||||||
|
<div class="settings-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>系统设置</h2>
|
||||||
|
<el-button type="primary" @click="saveAllSettings">保存所有设置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="settings-tabs">
|
||||||
|
<el-tab-pane label="邮件服务器" name="email">
|
||||||
|
<el-card class="settings-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>邮件服务器设置</span>
|
||||||
|
<el-button type="primary" size="small" @click="saveEmailSettings">保存邮件设置</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form
|
||||||
|
:model="emailSettings"
|
||||||
|
ref="emailFormRef"
|
||||||
|
label-width="120px"
|
||||||
|
:rules="emailRules"
|
||||||
|
v-loading="emailLoading"
|
||||||
|
>
|
||||||
|
<el-form-item label="SMTP服务器" prop="host">
|
||||||
|
<el-input v-model="emailSettings.host" placeholder="例如: smtp.example.com"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="SMTP端口" prop="port">
|
||||||
|
<el-input-number v-model="emailSettings.port" :min="1" :max="65535" placeholder="例如: 587"></el-input-number>
|
||||||
|
<span class="form-tip">常用端口: 25(非SSL), 465(SSL), 587(TLS)</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用SSL/TLS" prop="secure">
|
||||||
|
<el-switch v-model="emailSettings.secure"></el-switch>
|
||||||
|
<span class="form-tip">对应端口465通常启用SSL</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户名" prop="user">
|
||||||
|
<el-input v-model="emailSettings.user" placeholder="SMTP用户名/邮箱"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" prop="pass">
|
||||||
|
<el-input v-model="emailSettings.pass" type="password" placeholder="SMTP密码或应用专用密码"></el-input>
|
||||||
|
<span class="form-tip">如使用Gmail等服务,可能需要应用专用密码</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="发件人地址" prop="from">
|
||||||
|
<el-input v-model="emailSettings.from" placeholder="例如: pingping@example.com"></el-input>
|
||||||
|
<span class="form-tip">通知邮件的发件人地址</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-divider></el-divider>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="testEmailSettings">测试邮件配置</el-button>
|
||||||
|
<el-popover
|
||||||
|
placement="top-start"
|
||||||
|
title="测试邮件"
|
||||||
|
:width="300"
|
||||||
|
trigger="click"
|
||||||
|
v-model:visible="testEmailVisible"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<el-button>发送测试邮件</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="test-email-form">
|
||||||
|
<el-form>
|
||||||
|
<el-form-item label="收件人" prop="testEmail">
|
||||||
|
<el-input v-model="testEmail" placeholder="输入测试收件人邮箱"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="sendTestEmail" :loading="sendingTestEmail">
|
||||||
|
发送
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="通知设置" name="notification">
|
||||||
|
<el-card class="settings-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>通知设置</span>
|
||||||
|
<el-button type="primary" size="small" @click="saveNotificationSettings">保存通知设置</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form
|
||||||
|
:model="notificationSettings"
|
||||||
|
ref="notificationFormRef"
|
||||||
|
label-width="120px"
|
||||||
|
v-loading="notificationLoading"
|
||||||
|
>
|
||||||
|
<el-form-item label="通知频率限制" prop="throttle">
|
||||||
|
<el-select v-model="notificationSettings.throttle" placeholder="选择通知频率限制">
|
||||||
|
<el-option label="无限制" value="0"></el-option>
|
||||||
|
<el-option label="每5分钟最多一次" value="5"></el-option>
|
||||||
|
<el-option label="每15分钟最多一次" value="15"></el-option>
|
||||||
|
<el-option label="每30分钟最多一次" value="30"></el-option>
|
||||||
|
<el-option label="每小时最多一次" value="60"></el-option>
|
||||||
|
<el-option label="每天最多一次" value="1440"></el-option>
|
||||||
|
</el-select>
|
||||||
|
<span class="form-tip">限制同一监控项发送通知的频率</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="故障确认次数" prop="retryCount">
|
||||||
|
<el-input-number v-model="notificationSettings.retryCount" :min="1" :max="10"></el-input-number>
|
||||||
|
<span class="form-tip">连续故障多少次后才发送通知</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="恢复通知" prop="sendRecoveryNotification">
|
||||||
|
<el-switch v-model="notificationSettings.sendRecoveryNotification"></el-switch>
|
||||||
|
<span class="form-tip">服务恢复正常时是否发送通知</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态变更通知" prop="sendStatusChangeOnly">
|
||||||
|
<el-switch v-model="notificationSettings.sendStatusChangeOnly"></el-switch>
|
||||||
|
<span class="form-tip">仅在状态变更时发送通知,而不是持续发送</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="系统日志" name="logs">
|
||||||
|
<el-card class="settings-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>系统日志设置</span>
|
||||||
|
<el-button type="primary" size="small" @click="saveLogSettings">保存日志设置</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form
|
||||||
|
:model="logSettings"
|
||||||
|
ref="logFormRef"
|
||||||
|
label-width="120px"
|
||||||
|
v-loading="logLoading"
|
||||||
|
>
|
||||||
|
<el-form-item label="日志级别" prop="level">
|
||||||
|
<el-select v-model="logSettings.level" placeholder="选择日志级别">
|
||||||
|
<el-option label="调试" value="debug"></el-option>
|
||||||
|
<el-option label="信息" value="info"></el-option>
|
||||||
|
<el-option label="警告" value="warn"></el-option>
|
||||||
|
<el-option label="错误" value="error"></el-option>
|
||||||
|
</el-select>
|
||||||
|
<span class="form-tip">系统日志记录的详细程度</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="日志保留时间" prop="retention">
|
||||||
|
<el-select v-model="logSettings.retention" placeholder="选择日志保留时间">
|
||||||
|
<el-option label="7天" value="7"></el-option>
|
||||||
|
<el-option label="15天" value="15"></el-option>
|
||||||
|
<el-option label="30天" value="30"></el-option>
|
||||||
|
<el-option label="60天" value="60"></el-option>
|
||||||
|
<el-option label="90天" value="90"></el-option>
|
||||||
|
</el-select>
|
||||||
|
<span class="form-tip">系统自动清理超过保留时间的日志</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="日志文件路径" prop="filePath">
|
||||||
|
<el-input v-model="logSettings.filePath" placeholder="/var/log/pingping"></el-input>
|
||||||
|
<span class="form-tip">日志文件存储的路径(需要有写入权限)</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用控制台日志" prop="enableConsole">
|
||||||
|
<el-switch v-model="logSettings.enableConsole"></el-switch>
|
||||||
|
<span class="form-tip">是否在控制台输出日志</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用文件日志" prop="enableFile">
|
||||||
|
<el-switch v-model="logSettings.enableFile"></el-switch>
|
||||||
|
<span class="form-tip">是否将日志写入文件</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="settings-card" shadow="hover" style="margin-top: 20px;">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>日志管理</span>
|
||||||
|
<div>
|
||||||
|
<el-button type="primary" size="small" @click="downloadLogs">下载日志</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="clearLogs">清除日志</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="log-stats" v-loading="logStatsLoading">
|
||||||
|
<div class="log-stat-item">
|
||||||
|
<div class="stat-label">日志文件大小</div>
|
||||||
|
<div class="stat-value">{{ logStats.size || '未知' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-stat-item">
|
||||||
|
<div class="stat-label">最早日志时间</div>
|
||||||
|
<div class="stat-value">{{ logStats.oldestDate || '未知' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-stat-item">
|
||||||
|
<div class="stat-label">最新日志时间</div>
|
||||||
|
<div class="stat-value">{{ logStats.newestDate || '未知' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-stat-item">
|
||||||
|
<div class="stat-label">日志条目数</div>
|
||||||
|
<div class="stat-value">{{ logStats.entryCount || '未知' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// 标签页控制
|
||||||
|
const activeTab = ref('email');
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const emailLoading = ref(false);
|
||||||
|
const notificationLoading = ref(false);
|
||||||
|
const logLoading = ref(false);
|
||||||
|
const logStatsLoading = ref(false);
|
||||||
|
|
||||||
|
// 表单引用
|
||||||
|
const emailFormRef = ref();
|
||||||
|
const notificationFormRef = ref();
|
||||||
|
const logFormRef = ref();
|
||||||
|
|
||||||
|
// 测试邮件
|
||||||
|
const testEmailVisible = ref(false);
|
||||||
|
const testEmail = ref('');
|
||||||
|
const sendingTestEmail = ref(false);
|
||||||
|
|
||||||
|
// 邮件设置表单
|
||||||
|
const emailSettings = reactive({
|
||||||
|
host: '',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
user: '',
|
||||||
|
pass: '',
|
||||||
|
from: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通知设置表单
|
||||||
|
const notificationSettings = reactive({
|
||||||
|
throttle: '15', // 默认15分钟
|
||||||
|
retryCount: 3,
|
||||||
|
sendRecoveryNotification: true,
|
||||||
|
sendStatusChangeOnly: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 日志设置表单
|
||||||
|
const logSettings = reactive({
|
||||||
|
level: 'info',
|
||||||
|
retention: '30',
|
||||||
|
filePath: '',
|
||||||
|
enableConsole: true,
|
||||||
|
enableFile: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 日志统计信息
|
||||||
|
const logStats = reactive({
|
||||||
|
size: '',
|
||||||
|
oldestDate: '',
|
||||||
|
newestDate: '',
|
||||||
|
entryCount: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const emailRules = {
|
||||||
|
host: [
|
||||||
|
{ required: true, message: '请输入SMTP服务器地址', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
port: [
|
||||||
|
{ required: true, message: '请输入SMTP端口', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
user: [
|
||||||
|
{ required: true, message: '请输入SMTP用户名', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
pass: [
|
||||||
|
{ required: true, message: '请输入SMTP密码', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
from: [
|
||||||
|
{ required: true, message: '请输入发件人邮箱', trigger: 'blur' },
|
||||||
|
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取系统设置
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
// 获取邮件设置
|
||||||
|
await fetchEmailSettings();
|
||||||
|
|
||||||
|
// 获取通知设置
|
||||||
|
await fetchNotificationSettings();
|
||||||
|
|
||||||
|
// 获取日志设置
|
||||||
|
await fetchLogSettings();
|
||||||
|
|
||||||
|
// 获取日志统计
|
||||||
|
await fetchLogStats();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取邮件设置
|
||||||
|
const fetchEmailSettings = async () => {
|
||||||
|
emailLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/settings/email');
|
||||||
|
if (response.data.success) {
|
||||||
|
const data = response.data.data;
|
||||||
|
emailSettings.host = data.host || '';
|
||||||
|
emailSettings.port = data.port || 587;
|
||||||
|
emailSettings.secure = data.secure || false;
|
||||||
|
emailSettings.user = data.user || '';
|
||||||
|
emailSettings.pass = data.pass || '';
|
||||||
|
emailSettings.from = data.from || '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取邮件设置失败:', error);
|
||||||
|
ElMessage.error('获取邮件设置失败');
|
||||||
|
} finally {
|
||||||
|
emailLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取通知设置
|
||||||
|
const fetchNotificationSettings = async () => {
|
||||||
|
notificationLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/settings/notification');
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
const data = response.data.data;
|
||||||
|
notificationSettings.throttle = data.throttle?.toString() || '15';
|
||||||
|
notificationSettings.retryCount = data.retryCount || 3;
|
||||||
|
notificationSettings.sendRecoveryNotification = data.sendRecoveryNotification || true;
|
||||||
|
notificationSettings.sendStatusChangeOnly = data.sendStatusChangeOnly || true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取通知设置失败:', error);
|
||||||
|
// 保持默认设置
|
||||||
|
} finally {
|
||||||
|
notificationLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取日志设置
|
||||||
|
const fetchLogSettings = async () => {
|
||||||
|
logLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/settings/logs');
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
const data = response.data.data;
|
||||||
|
logSettings.level = data.level || 'info';
|
||||||
|
logSettings.retention = data.retention?.toString() || '30';
|
||||||
|
logSettings.filePath = data.filePath || '';
|
||||||
|
logSettings.enableConsole = data.enableConsole || true;
|
||||||
|
logSettings.enableFile = data.enableFile || true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取日志设置失败:', error);
|
||||||
|
// 保持默认设置
|
||||||
|
} finally {
|
||||||
|
logLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取日志统计
|
||||||
|
const fetchLogStats = async () => {
|
||||||
|
logStatsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/settings/logs/stats');
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
const data = response.data.data;
|
||||||
|
logStats.size = data.size || '0 KB';
|
||||||
|
logStats.oldestDate = data.oldestDate || '无数据';
|
||||||
|
logStats.newestDate = data.newestDate || '无数据';
|
||||||
|
logStats.entryCount = data.entryCount?.toString() || '0';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取日志统计失败:', error);
|
||||||
|
// 保持默认值
|
||||||
|
} finally {
|
||||||
|
logStatsLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存所有设置
|
||||||
|
const saveAllSettings = async () => {
|
||||||
|
// 保存邮件设置
|
||||||
|
await saveEmailSettings();
|
||||||
|
|
||||||
|
// 保存通知设置
|
||||||
|
await saveNotificationSettings();
|
||||||
|
|
||||||
|
// 保存日志设置
|
||||||
|
await saveLogSettings();
|
||||||
|
|
||||||
|
ElMessage.success('所有设置已保存');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存邮件设置
|
||||||
|
const saveEmailSettings = async () => {
|
||||||
|
if (!emailFormRef.value) return;
|
||||||
|
|
||||||
|
await emailFormRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (valid) {
|
||||||
|
emailLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/settings/email', emailSettings);
|
||||||
|
if (response.data.success) {
|
||||||
|
ElMessage.success('保存邮件设置成功');
|
||||||
|
} else {
|
||||||
|
ElMessage.error(response.data.message || '保存失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('保存邮件设置失败:', error);
|
||||||
|
ElMessage.error(error.response?.data?.message || '保存邮件设置失败');
|
||||||
|
} finally {
|
||||||
|
emailLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存通知设置
|
||||||
|
const saveNotificationSettings = async () => {
|
||||||
|
notificationLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/settings/notification', {
|
||||||
|
throttle: parseInt(notificationSettings.throttle),
|
||||||
|
retryCount: notificationSettings.retryCount,
|
||||||
|
sendRecoveryNotification: notificationSettings.sendRecoveryNotification,
|
||||||
|
sendStatusChangeOnly: notificationSettings.sendStatusChangeOnly
|
||||||
|
});
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
ElMessage.success('保存通知设置成功');
|
||||||
|
} else {
|
||||||
|
ElMessage.error(response.data?.message || '保存通知设置失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('保存通知设置失败:', error);
|
||||||
|
ElMessage.error(error.response?.data?.message || '保存通知设置失败');
|
||||||
|
} finally {
|
||||||
|
notificationLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存日志设置
|
||||||
|
const saveLogSettings = async () => {
|
||||||
|
logLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/settings/logs', {
|
||||||
|
level: logSettings.level,
|
||||||
|
retention: parseInt(logSettings.retention),
|
||||||
|
filePath: logSettings.filePath,
|
||||||
|
enableConsole: logSettings.enableConsole,
|
||||||
|
enableFile: logSettings.enableFile
|
||||||
|
});
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
ElMessage.success('保存日志设置成功');
|
||||||
|
} else {
|
||||||
|
ElMessage.error(response.data?.message || '保存日志设置失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('保存日志设置失败:', error);
|
||||||
|
ElMessage.error(error.response?.data?.message || '保存日志设置失败');
|
||||||
|
} finally {
|
||||||
|
logLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试邮件设置
|
||||||
|
const testEmailSettings = async () => {
|
||||||
|
if (!emailFormRef.value) return;
|
||||||
|
|
||||||
|
await emailFormRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/settings/email/test', emailSettings);
|
||||||
|
if (response.data.success) {
|
||||||
|
ElMessage.success('邮件配置测试成功');
|
||||||
|
} else {
|
||||||
|
ElMessage.error(response.data.message || '测试失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('测试邮件配置失败:', error);
|
||||||
|
ElMessage.error(error.response?.data?.message || '测试邮件配置失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送测试邮件
|
||||||
|
const sendTestEmail = async () => {
|
||||||
|
if (!testEmail.value) {
|
||||||
|
ElMessage.warning('请输入收件人邮箱');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emailFormRef.value) return;
|
||||||
|
|
||||||
|
await emailFormRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (valid) {
|
||||||
|
sendingTestEmail.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/settings/email/send-test', {
|
||||||
|
...emailSettings,
|
||||||
|
to: testEmail.value
|
||||||
|
});
|
||||||
|
if (response.data.success) {
|
||||||
|
ElMessage.success('测试邮件发送成功');
|
||||||
|
testEmailVisible.value = false;
|
||||||
|
} else {
|
||||||
|
ElMessage.error(response.data.message || '发送失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('发送测试邮件失败:', error);
|
||||||
|
ElMessage.error(error.response?.data?.message || '发送测试邮件失败');
|
||||||
|
} finally {
|
||||||
|
sendingTestEmail.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下载日志
|
||||||
|
const downloadLogs = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/settings/logs/download', {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `pingping-logs-${new Date().toISOString().split('T')[0]}.zip`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
ElMessage.success('日志下载成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载日志失败:', error);
|
||||||
|
ElMessage.error('下载日志失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除日志
|
||||||
|
const clearLogs = async () => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要清除所有日志吗?此操作不可恢复', '警告', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axios.post('/api/settings/logs/clear');
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
ElMessage.success('日志已清除');
|
||||||
|
fetchLogStats(); // 刷新日志统计
|
||||||
|
} else {
|
||||||
|
ElMessage.error(response.data?.message || '清除日志失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('清除日志失败:', error);
|
||||||
|
ElMessage.error(error.response?.data?.message || '清除日志失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSettings();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-email-form {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-stat-item {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗黑模式样式适配 */
|
||||||
|
:root[data-theme="dark"] .settings-container,
|
||||||
|
html.dark .settings-container {
|
||||||
|
background-color: #141414;
|
||||||
|
color: #e5eaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .page-header h2,
|
||||||
|
html.dark .page-header h2 {
|
||||||
|
color: #e5eaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .card-header,
|
||||||
|
html.dark .card-header {
|
||||||
|
color: #e5eaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .form-tip,
|
||||||
|
html.dark .form-tip {
|
||||||
|
color: #a6abb5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .log-stat-item,
|
||||||
|
html.dark .log-stat-item {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .stat-label,
|
||||||
|
html.dark .stat-label {
|
||||||
|
color: #a6abb5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .stat-value,
|
||||||
|
html.dark .stat-value {
|
||||||
|
color: #e5eaf3;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), vueJsx()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:2070/api',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
Loading…
Reference in New Issue