Initial commit

This commit is contained in:
Marsway 2025-06-09 17:10:23 +08:00
commit 2a84528345
56 changed files with 23000 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

132
README.md Normal file
View File

@ -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

28
backend/Dockerfile Normal file
View File

@ -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"]

View File

@ -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.

4183
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
backend/package.json Normal file
View File

@ -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"
}
}

2806
backend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- better-sqlite3
- sqlite3

View File

@ -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',
},
},
};

View File

@ -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;

View File

@ -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;

95
backend/src/index.ts Normal file
View File

@ -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();

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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();

View File

@ -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();

19
backend/tsconfig.json Normal file
View File

@ -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"]
}

42
docker-compose.yml Normal file
View File

@ -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

27
ecosystem.config.js Normal file
View File

@ -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',
},
},
],
};

25
frontend/.eslintrc.js Normal file
View File

@ -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',
},
};

24
frontend/.gitignore vendored Normal file
View File

@ -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?

9
frontend/.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"endOfLine": "auto"
}

3
frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

34
frontend/Dockerfile Normal file
View File

@ -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;"]

5
frontend/README.md Normal file
View File

@ -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).

13
frontend/index.html Normal file
View File

@ -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>

20
frontend/nginx.conf Normal file
View File

@ -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;
}
}

4508
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View File

@ -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"
}
}

2809
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

1
frontend/public/vite.svg Normal file
View File

@ -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

245
frontend/src/App.vue Normal file
View File

@ -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>

235
frontend/src/App.vue.backup Normal file
View File

@ -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
frontend/src/App.vue.new Normal file
View File

View File

@ -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

View File

@ -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>

16
frontend/src/main.ts Normal file
View File

@ -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
frontend/src/main.ts.new Normal file
View File

View File

@ -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;

168
frontend/src/style.css Normal file
View File

@ -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;
}
}

37
frontend/src/utils/api.ts Normal file
View File

@ -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;

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -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"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -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"]
}

18
frontend/vite.config.ts Normal file
View File

@ -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/, '')
}
}
}
})