├── src ├── app │ ├── api │ │ ├── system │ │ │ ├── initialize │ │ │ │ └── route.ts │ │ │ └── init │ │ │ │ └── route.ts │ │ ├── monitors │ │ │ ├── reset │ │ │ │ └── route.ts │ │ │ ├── stop │ │ │ │ └── route.ts │ │ │ ├── start │ │ │ │ └── route.ts │ │ │ ├── reorder │ │ │ │ └── route.ts │ │ │ ├── [id] │ │ │ │ └── history │ │ │ │ │ └── route.ts │ │ │ ├── template │ │ │ │ └── route.ts │ │ │ └── batch │ │ │ │ └── route.ts │ │ ├── settings │ │ │ ├── notifications │ │ │ │ ├── [id] │ │ │ │ │ ├── default │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── password │ │ │ │ └── route.ts │ │ │ ├── debug │ │ │ │ └── route.ts │ │ │ ├── proxy-test │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── status-pages │ │ │ ├── [id] │ │ │ │ ├── monitors │ │ │ │ │ ├── [monitorId] │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── auth │ │ │ ├── register │ │ │ │ └── route.ts │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── user │ │ │ └── login-records │ │ │ │ └── route.ts │ │ ├── push │ │ │ └── [token] │ │ │ │ └── route.ts │ │ ├── monitor-groups │ │ │ ├── route.ts │ │ │ └── [id] │ │ │ │ └── route.ts │ │ └── status │ │ │ └── [slug] │ │ │ └── route.ts │ ├── favicon.ico │ ├── metadata.ts │ ├── auth │ │ ├── register │ │ │ └── page.tsx │ │ ├── login │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── components │ │ │ └── login-form.tsx │ ├── setup │ │ ├── layout.tsx │ │ └── page.tsx │ ├── page.tsx │ ├── dashboard │ │ ├── layout.tsx │ │ ├── login-records │ │ │ └── page.tsx │ │ ├── monitors │ │ │ ├── components │ │ │ │ ├── MonitorTypeSelector.tsx │ │ │ │ ├── MonitorSettingsSection.tsx │ │ │ │ └── DatabaseOptionsSection.tsx │ │ │ └── history │ │ │ │ └── page.tsx │ │ └── status-pages │ │ │ ├── page.tsx │ │ │ └── components │ │ │ └── status-page-list.tsx │ └── layout.tsx ├── tests │ ├── vitest.setup.ts │ └── monitors │ │ ├── checker-push.test.ts │ │ └── checker-ports.test.ts ├── lib │ ├── prisma.ts │ ├── auth-helpers.ts │ ├── monitors │ │ ├── index.ts │ │ ├── status-recorder.ts │ │ ├── data-cleaner.ts │ │ ├── types.ts │ │ ├── checker-push.ts │ │ ├── utils.ts │ │ ├── checker-icmp.ts │ │ ├── checker.ts │ │ ├── checker-ports.ts │ │ └── proxy-fetch.ts │ ├── startup.ts │ ├── utils │ │ ├── compact-id.ts │ │ ├── compact-message.ts │ │ └── ultra-compact-id.ts │ ├── system-config.ts │ └── monitors.ts ├── types │ ├── next-auth.d.ts │ └── monitor.ts ├── instrumentation.ts ├── context │ └── AuthContext.tsx └── components │ ├── settings │ └── about-settings.tsx │ ├── monitor-history-item.tsx │ ├── confirm-dialog.tsx │ └── theme-provider.tsx ├── screenshot ├── add.png ├── dashboard-one.png ├── notification.png └── dashboard-main.png ├── data └── coolmonitor.db ├── .env ├── postcss.config.js ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── prisma └── migrations │ ├── migration_lock.toml │ ├── 20250423075233_update_settings_schema │ └── migration.sql │ ├── 20250422025109_init │ └── migration.sql │ ├── 20250422045057_make_email_optional │ └── migration.sql │ ├── 20250422045808_add_username_field │ └── migration.sql │ ├── 20250426040543_add_monitor_notification │ └── migration.sql │ ├── 20250423015548_init │ └── migration.sql │ ├── 20250729060305_add_status_page │ └── migration.sql │ ├── 20250807031821_add_monitor_groups │ └── migration.sql │ └── 20250422030024_add_system_config │ └── migration.sql ├── .eslintrc.json ├── .eslintrc.js ├── eslint.config.mjs ├── next.config.js ├── .gitignore ├── docker-compose.arm.yml ├── vitest.config.ts ├── docker-compose.yml ├── .dockerignore ├── tsconfig.json ├── startup.sh ├── package.json ├── tailwind.config.ts ├── .github └── workflows │ └── docker.yml ├── Dockerfile-ARM ├── README.md └── Dockerfile /src/app/api/system/initialize/route.ts: -------------------------------------------------------------------------------- 1 | // 这个API端点已被移除,改为直接调用函数 -------------------------------------------------------------------------------- /screenshot/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/star7th/coolmonitor/master/screenshot/add.png -------------------------------------------------------------------------------- /data/coolmonitor.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/star7th/coolmonitor/master/data/coolmonitor.db -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/star7th/coolmonitor/master/src/app/favicon.ico -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # NextAuth.js 密钥 2 | NEXTAUTH_SECRET=6366f18b5cy6f1f5f994a3b8f59e0bef4444cbd36f94a3b8cbd5e194a3b8f59e -------------------------------------------------------------------------------- /screenshot/dashboard-one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/star7th/coolmonitor/master/screenshot/dashboard-one.png -------------------------------------------------------------------------------- /screenshot/notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/star7th/coolmonitor/master/screenshot/notification.png -------------------------------------------------------------------------------- /screenshot/dashboard-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/star7th/coolmonitor/master/screenshot/dashboard-main.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "sqlite" 4 | -------------------------------------------------------------------------------- /src/app/metadata.ts: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | export const metadata: Metadata = { 4 | title: "CoolMonitor - 高颜值监控工具", 5 | description: "支持网站监控/接口监控/https证书监控等多类监控项", 6 | }; -------------------------------------------------------------------------------- /src/app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | export default async function RegisterPage() { 4 | // 普通注册页面直接重定向到登录页 5 | return redirect('/auth/login'); 6 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "@typescript-eslint/no-unused-vars": "off", 5 | "@typescript-eslint/no-explicit-any": "off", 6 | "no-var": "off", 7 | "react-hooks/exhaustive-deps": "off" 8 | } 9 | } -------------------------------------------------------------------------------- /src/app/setup/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function SetupLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode; 5 | }) { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'next/core-web-vitals', 3 | rules: { 4 | '@typescript-eslint/no-unused-vars': 'off', 5 | '@typescript-eslint/no-explicit-any': 'off', 6 | 'no-var': 'off', 7 | 'react-hooks/exhaustive-deps': 'off' 8 | }, 9 | ignorePatterns: ['**/*'] 10 | }; -------------------------------------------------------------------------------- /src/tests/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | // vitest.setup.ts 2 | import { expect, afterEach } from 'vitest'; 3 | import { cleanup } from '@testing-library/react'; 4 | import * as matchers from '@testing-library/jest-dom'; 5 | 6 | // 扩展Vitest的断言能力,添加来自jest-dom的matchers 7 | expect.extend(matchers); 8 | 9 | // 每个测试后自动清理 10 | afterEach(() => { 11 | cleanup(); 12 | }); -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | // 声明全局变量以避免热重载期间连接过多 4 | declare global { 5 | var prisma: PrismaClient | undefined; 6 | } 7 | 8 | // 在开发环境中使用全局变量,在生产环境中创建新实例 9 | export const prisma = global.prisma || new PrismaClient(); 10 | 11 | if (process.env.NODE_ENV !== 'production') { 12 | global.prisma = prisma; 13 | } 14 | 15 | export default prisma; -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /prisma/migrations/20250423075233_update_settings_schema/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "NotificationChannel" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "name" TEXT NOT NULL, 5 | "type" TEXT NOT NULL, 6 | "enabled" BOOLEAN NOT NULL DEFAULT true, 7 | "config" JSONB NOT NULL, 8 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "updatedAt" DATETIME NOT NULL 10 | ); 11 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { DefaultSession } from 'next-auth'; 2 | 3 | declare module 'next-auth' { 4 | interface User { 5 | id: string; 6 | isAdmin: boolean; 7 | } 8 | 9 | interface Session extends DefaultSession { 10 | user?: { 11 | id: string; 12 | isAdmin: boolean; 13 | } & DefaultSession['user']; 14 | } 15 | } 16 | 17 | declare module 'next-auth/jwt' { 18 | interface JWT { 19 | id: string; 20 | isAdmin: boolean; 21 | } 22 | } -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { hasAdminUser } from '@/lib/auth'; 3 | 4 | export default async function Home() { 5 | try { 6 | // 检查是否有管理员用户 7 | const hasAdmin = await hasAdminUser(); 8 | 9 | if (hasAdmin) { 10 | // 已有管理员,重定向到登录页 11 | redirect("/auth/login"); 12 | } else { 13 | // 无管理员,重定向到系统初始化页面 14 | redirect("/setup"); 15 | } 16 | } catch (error) { 17 | console.error("检查管理员状态时出错:", error); 18 | // 出错时默认重定向到系统初始化页面 19 | redirect("/setup"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | /* config options here */ 6 | output: 'standalone', 7 | webpack: (config) => { 8 | config.resolve.fallback = { 9 | ...config.resolve.fallback, 10 | net: false, 11 | tls: false, 12 | dns: false, 13 | fs: false, 14 | }; 15 | 16 | return config; 17 | }, 18 | // 设置服务端默认端口 19 | env: { 20 | PORT: '3333', 21 | }, 22 | typescript: { 23 | ignoreBuildErrors: true 24 | } 25 | }; 26 | 27 | module.exports = nextConfig; -------------------------------------------------------------------------------- /src/app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import LoginForm from '../components/login-form'; 2 | import { getServerSession } from 'next-auth'; 3 | import { redirect } from 'next/navigation'; 4 | import { buildAuthOptions } from '@/app/api/auth/[...nextauth]/route'; 5 | 6 | export default async function LoginPage() { 7 | // 检查用户是否已登录,使用相同的 authOptions 配置 8 | const authOptions = await buildAuthOptions(); 9 | const session = await getServerSession(authOptions); 10 | 11 | // 如果已经登录,直接跳转到仪表盘 12 | if (session) { 13 | return redirect('/dashboard'); 14 | } 15 | 16 | // 未登录,显示登录表单 17 | return ; 18 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | 41 | data/backup/* -------------------------------------------------------------------------------- /src/app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function AuthLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode; 5 | }) { 6 | return ( 7 |
8 |
9 |
10 |

11 | 酷监控 12 |

13 |

高颜值网站和接口监控工具

14 |
15 |
16 | {children} 17 |
18 |
19 |
20 | ); 21 | } -------------------------------------------------------------------------------- /docker-compose.arm.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | coolmonitor: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile-ARM 8 | restart: always 9 | ports: 10 | - "3333:3333" 11 | volumes: 12 | # 挂载本地目录,用于持久化SQLite数据库 13 | # 首次启动时会自动初始化数据库 14 | - ~/coolmonitor_data:/app/data 15 | environment: 16 | - NODE_ENV=production 17 | - PORT=3333 18 | - NODE_OPTIONS=--max-old-space-size=2048 19 | # 如果需要设置代理,取消以下注释 20 | # - PROXY_ENABLED=true 21 | # - PROXY_SERVER=your-proxy-server 22 | # - PROXY_PORT=your-proxy-port 23 | # - PROXY_USERNAME=your-proxy-username 24 | # - PROXY_PASSWORD=your-proxy-password 25 | 26 | # 不再需要定义卷,因为直接使用本地目录 -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // vitest.config.ts 2 | import { defineConfig } from 'vitest/config'; 3 | import react from '@vitejs/plugin-react'; 4 | import path from 'path'; 5 | 6 | export default defineConfig({ 7 | plugins: [react()], 8 | test: { 9 | environment: 'jsdom', 10 | setupFiles: ['./src/tests/vitest.setup.ts'], 11 | globals: true, 12 | coverage: { 13 | provider: 'v8', 14 | reporter: ['text', 'json', 'html'], 15 | exclude: [ 16 | 'node_modules/**', 17 | 'src/tests/**', 18 | '.next/**', 19 | 'prisma/**', 20 | 'public/**', 21 | ] 22 | } 23 | }, 24 | resolve: { 25 | alias: { 26 | '@': path.resolve(__dirname, 'src') 27 | } 28 | } 29 | }); -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | coolmonitor: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | restart: always 9 | ports: 10 | - "3333:3333" 11 | volumes: 12 | # 挂载本地目录,用于持久化SQLite数据库 13 | # 首次启动时会自动初始化数据库 14 | - ~/coolmonitor_data:/app/data 15 | environment: 16 | - NODE_ENV=production 17 | - PORT=3333 18 | # 允许在Docker环境中进入安装界面的配置 19 | - DOCKER_SETUP=true 20 | # 如果需要设置代理,取消以下注释 21 | # - PROXY_ENABLED=true 22 | # - PROXY_SERVER=your-proxy-server 23 | # - PROXY_PORT=your-proxy-port 24 | # - PROXY_USERNAME=your-proxy-username 25 | # - PROXY_PASSWORD=your-proxy-password 26 | 27 | # 不再需要定义卷,因为直接使用本地目录 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | 5 | # 数据目录 6 | /data/* 7 | !/data/.gitkeep 8 | 9 | # Node.js 10 | node_modules 11 | npm-debug.log 12 | yarn-debug.log 13 | yarn-error.log 14 | 15 | # Next.js 16 | .next 17 | out 18 | 19 | # 环境变量 (允许复制到容器) 20 | # .env 21 | # .env.* 22 | !.env.example 23 | 24 | # 编辑器 25 | .idea 26 | .vscode 27 | *.swp 28 | *.swo 29 | 30 | # 操作系统 31 | .DS_Store 32 | Thumbs.db 33 | 34 | # 调试 35 | npm-debug.log* 36 | yarn-debug.log* 37 | yarn-error.log* 38 | 39 | # Docker相关 40 | Dockerfile 41 | Dockerfile-ARM 42 | docker-compose.yml 43 | docker-compose.arm.yml 44 | .dockerignore 45 | 46 | # 测试文件 47 | coverage 48 | .nyc_output 49 | __tests__ 50 | **/*.test.ts 51 | **/*.spec.ts 52 | 53 | # 其他 54 | README.md 55 | LICENSE 56 | *.log -------------------------------------------------------------------------------- /src/app/api/monitors/reset/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { resetAllMonitors } from '@/lib/monitors/scheduler'; 3 | import { validateAuth } from '@/lib/auth-helpers'; 4 | 5 | // POST /api/monitors/reset - 重置所有监控 6 | export async function POST() { 7 | try { 8 | // 验证用户是否已登录 9 | const authError = await validateAuth(); 10 | if (authError) return authError; 11 | 12 | // 重置并重新启动所有激活的监控项 13 | const count = await resetAllMonitors(); 14 | 15 | return NextResponse.json({ 16 | message: `监控重置成功,已启动 ${count} 个监控项` 17 | }); 18 | } catch (error) { 19 | console.error('重置监控失败:', error); 20 | return NextResponse.json( 21 | { error: '重置监控失败,请稍后重试' }, 22 | { status: 500 } 23 | ); 24 | } 25 | } -------------------------------------------------------------------------------- /src/instrumentation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Next.js Instrumentation 3 | * 在服务器启动时自动初始化监控系统 4 | */ 5 | 6 | export async function register() { 7 | if (process.env.NEXT_RUNTIME === 'nodejs') { 8 | try { 9 | console.log('========================'); 10 | console.log('系统启动中,执行自动初始化...'); 11 | 12 | // 动态导入初始化模块 13 | const { forceInitSystem } = await import('./lib/startup'); 14 | 15 | // 调用系统初始化函数 16 | const result = await forceInitSystem(); 17 | 18 | if (result) { 19 | console.log('系统初始化成功'); 20 | } else { 21 | console.log('系统初始化失败或无需初始化'); 22 | } 23 | console.log('========================'); 24 | } catch (error) { 25 | console.error('系统自动初始化过程中发生错误:', error); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./src/*" 28 | ] 29 | }, 30 | "strictNullChecks": true 31 | }, 32 | "include": [ 33 | "next-env.d.ts", 34 | "**/*.ts", 35 | "**/*.tsx", 36 | ".next/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /prisma/migrations/20250422025109_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "name" TEXT, 5 | "email" TEXT NOT NULL, 6 | "password" TEXT NOT NULL, 7 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" DATETIME NOT NULL, 9 | "isAdmin" BOOLEAN NOT NULL DEFAULT false 10 | ); 11 | 12 | -- CreateTable 13 | CREATE TABLE "Session" ( 14 | "id" TEXT NOT NULL PRIMARY KEY, 15 | "sessionToken" TEXT NOT NULL, 16 | "userId" TEXT NOT NULL, 17 | "expires" DATETIME NOT NULL, 18 | CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 19 | ); 20 | 21 | -- CreateIndex 22 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 23 | 24 | -- CreateIndex 25 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 26 | -------------------------------------------------------------------------------- /src/app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession } from 'next-auth'; 2 | import { redirect } from 'next/navigation'; 3 | import { buildAuthOptions } from '../api/auth/[...nextauth]/route'; 4 | 5 | export default async function DashboardLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | try { 11 | // 获取自定义authOptions确保与登录使用相同的配置 12 | const authOptions = await buildAuthOptions(); 13 | 14 | // 检查用户是否已登录 15 | const session = await getServerSession(authOptions); 16 | 17 | // 如果没有有效会话,则重定向到登录页 18 | if (!session || !session.user) { 19 | console.log('未检测到有效会话,重定向到登录页'); 20 | return redirect('/auth/login?from=dashboard'); 21 | } 22 | 23 | // 用户已登录,渲染仪表盘内容 24 | return <>{children}; 25 | } catch (error) { 26 | console.error('处理仪表盘会话出错:', error); 27 | 28 | // 出错时也重定向到登录页,但添加错误参数便于调试 29 | return redirect('/auth/login?error=session_error'); 30 | } 31 | } -------------------------------------------------------------------------------- /src/app/api/monitors/stop/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { stopMonitor } from '@/lib/monitors/scheduler'; 3 | import { validateAuth } from '@/lib/auth-helpers'; 4 | 5 | // POST /api/monitors/stop - 停止监控 6 | export async function POST(request: Request) { 7 | try { 8 | // 验证用户是否已登录 9 | const authError = await validateAuth(); 10 | if (authError) return authError; 11 | 12 | const { id } = await request.json(); 13 | 14 | if (!id) { 15 | return NextResponse.json( 16 | { error: '监控项ID为必填项' }, 17 | { status: 400 } 18 | ); 19 | } 20 | 21 | // 停止该监控项 22 | const result = stopMonitor(id); 23 | 24 | return NextResponse.json({ 25 | message: result ? '监控停止成功' : '监控不在运行状态或不存在' 26 | }); 27 | } catch (error) { 28 | console.error('停止监控失败:', error); 29 | return NextResponse.json( 30 | { error: '停止监控失败,请稍后重试' }, 31 | { status: 500 } 32 | ); 33 | } 34 | } -------------------------------------------------------------------------------- /prisma/migrations/20250422045057_make_email_optional/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column. 5 | 6 | */ 7 | -- RedefineTables 8 | PRAGMA defer_foreign_keys=ON; 9 | PRAGMA foreign_keys=OFF; 10 | CREATE TABLE "new_User" ( 11 | "id" TEXT NOT NULL PRIMARY KEY, 12 | "name" TEXT NOT NULL, 13 | "email" TEXT, 14 | "password" TEXT NOT NULL, 15 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | "updatedAt" DATETIME NOT NULL, 17 | "isAdmin" BOOLEAN NOT NULL DEFAULT false 18 | ); 19 | INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "name", "password", "updatedAt") SELECT "createdAt", "email", "id", "isAdmin", "name", "password", "updatedAt" FROM "User"; 20 | DROP TABLE "User"; 21 | ALTER TABLE "new_User" RENAME TO "User"; 22 | CREATE UNIQUE INDEX "User_name_key" ON "User"("name"); 23 | PRAGMA foreign_keys=ON; 24 | PRAGMA defer_foreign_keys=OFF; 25 | -------------------------------------------------------------------------------- /src/context/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { SessionProvider } from 'next-auth/react'; 4 | import { ReactNode, useEffect, useState } from 'react'; 5 | 6 | interface AuthContextProps { 7 | children: ReactNode; 8 | } 9 | 10 | export default function AuthContext({ children }: AuthContextProps) { 11 | const [hasError, setHasError] = useState(false); 12 | 13 | // 监听会话错误并记录 14 | useEffect(() => { 15 | // 检查URL参数是否有会话错误 16 | const urlParams = new URLSearchParams(window.location.search); 17 | const sessionError = urlParams.get('error'); 18 | 19 | if (sessionError) { 20 | console.error('检测到会话错误:', sessionError); 21 | setHasError(true); 22 | } 23 | 24 | console.log('AuthContext 已初始化'); 25 | }, []); 26 | 27 | return ( 28 | 32 | {children} 33 | 34 | ); 35 | } -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/auth-helpers.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from 'next-auth'; 2 | import { buildAuthOptions } from '@/app/api/auth/[...nextauth]/route'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | /** 6 | * 检查用户是否已登录的辅助函数 7 | * @returns 如果用户已登录返回true,否则返回false 8 | */ 9 | export async function isAuthenticated() { 10 | const authOptions = await buildAuthOptions(); 11 | const session = await getServerSession(authOptions); 12 | 13 | return !!(session && session.user); 14 | } 15 | 16 | /** 17 | * 处理未授权请求的通用函数 18 | * @returns NextResponse 包含401状态码和错误消息 19 | */ 20 | export function unauthorized() { 21 | return NextResponse.json( 22 | { error: '未授权的请求,请先登录' }, 23 | { status: 401 } 24 | ); 25 | } 26 | 27 | /** 28 | * 验证API路由的中间件函数 29 | * 检查用户是否已登录,如果未登录则返回401响应 30 | * @returns 如果用户已登录则返回null,否则返回401 Response 31 | */ 32 | export async function validateAuth() { 33 | const authenticated = await isAuthenticated(); 34 | if (!authenticated) { 35 | return unauthorized(); 36 | } 37 | return null; 38 | } -------------------------------------------------------------------------------- /src/app/setup/page.tsx: -------------------------------------------------------------------------------- 1 | import SetupForm from '@/app/auth/components/setup-form'; 2 | import { hasAdminUser } from '@/lib/auth'; 3 | import { getServerSession } from 'next-auth'; 4 | import { redirect } from 'next/navigation'; 5 | 6 | export default async function SetupPage() { 7 | try { 8 | // 检查是否已有管理员(第一优先级) 9 | const hasAdmin = await hasAdminUser(); 10 | 11 | // 如果已有管理员,无论如何都重定向到登录页 12 | if (hasAdmin) { 13 | console.log("系统已初始化(有管理员),重定向到登录页"); 14 | return redirect('/auth/login'); 15 | } 16 | 17 | // 检查用户是否已登录(第二优先级) 18 | const session = await getServerSession(); 19 | 20 | // 如果已经登录,直接跳转到仪表盘 21 | if (session) { 22 | console.log("用户已登录,重定向到仪表盘"); 23 | return redirect('/dashboard'); 24 | } 25 | } catch (error) { 26 | console.error("检查管理员状态出错:", error); 27 | // 出错时也重定向到登录页,确保安全 28 | return redirect('/auth/login'); 29 | } 30 | 31 | // 未登录且无管理员,显示系统初始化表单 32 | console.log("显示系统初始化表单"); 33 | return ( 34 | 35 | ); 36 | } -------------------------------------------------------------------------------- /src/app/api/settings/notifications/[id]/default/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { toggleNotificationChannelDefault } from '@/lib/settings'; 3 | import { validateAuth } from '@/lib/auth-helpers'; 4 | 5 | // 切换通知渠道的默认状态 6 | export async function PATCH( 7 | request: NextRequest, 8 | { params }: { params: { id: string } } 9 | ) { 10 | try { 11 | // 验证用户是否已登录 12 | const authError = await validateAuth(); 13 | if (authError) return authError; 14 | 15 | const id = params.id; 16 | 17 | if (!id) { 18 | return NextResponse.json( 19 | { success: false, error: '缺少通知渠道ID' }, 20 | { status: 400 } 21 | ); 22 | } 23 | 24 | await toggleNotificationChannelDefault(id); 25 | 26 | return NextResponse.json({ success: true }); 27 | } catch (error) { 28 | console.error('切换通知渠道默认状态失败:', error); 29 | return NextResponse.json( 30 | { success: false, error: '切换通知渠道默认状态失败' }, 31 | { status: 500 } 32 | ); 33 | } 34 | } -------------------------------------------------------------------------------- /prisma/migrations/20250422045808_add_username_field/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- RedefineTables 8 | PRAGMA defer_foreign_keys=ON; 9 | PRAGMA foreign_keys=OFF; 10 | CREATE TABLE "new_User" ( 11 | "id" TEXT NOT NULL PRIMARY KEY, 12 | "username" TEXT NOT NULL, 13 | "name" TEXT, 14 | "email" TEXT, 15 | "password" TEXT NOT NULL, 16 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | "updatedAt" DATETIME NOT NULL, 18 | "isAdmin" BOOLEAN NOT NULL DEFAULT false 19 | ); 20 | INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "name", "password", "updatedAt") SELECT "createdAt", "email", "id", "isAdmin", "name", "password", "updatedAt" FROM "User"; 21 | DROP TABLE "User"; 22 | ALTER TABLE "new_User" RENAME TO "User"; 23 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 24 | PRAGMA foreign_keys=ON; 25 | PRAGMA defer_foreign_keys=OFF; 26 | -------------------------------------------------------------------------------- /src/lib/monitors/index.ts: -------------------------------------------------------------------------------- 1 | import { checkHttp, checkKeyword, checkHttpsCertificate } from './checker-http'; 2 | import { checkPort } from './checker-ports'; 3 | import { checkDatabase } from './checker-database'; 4 | import { checkPush } from './checker-push'; 5 | import { checkIcmp } from './checker-icmp'; 6 | import { MONITOR_STATUS, ERROR_MESSAGES } from './types'; 7 | import { scheduleMonitor, stopMonitor, resetAllMonitors } from './scheduler'; 8 | 9 | export * from './types'; 10 | export * from './utils'; 11 | export * from './scheduler'; 12 | 13 | // 导出所有检查器 14 | export const checkers = { 15 | http: checkHttp, 16 | keyword: checkKeyword, 17 | "https-cert": checkHttpsCertificate, 18 | port: checkPort, 19 | database: checkDatabase, 20 | push: checkPush, 21 | icmp: checkIcmp 22 | }; 23 | 24 | // 导出调度器 25 | export const scheduler = { 26 | schedule: scheduleMonitor, 27 | stop: stopMonitor, 28 | resetAll: resetAllMonitors 29 | }; 30 | 31 | // 导出常量 32 | export const monitorStatus = MONITOR_STATUS; 33 | export const errorMessages = ERROR_MESSAGES; -------------------------------------------------------------------------------- /src/app/api/monitors/start/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { scheduleMonitor } from '@/lib/monitors/scheduler'; 3 | import { validateAuth } from '@/lib/auth-helpers'; 4 | 5 | // POST /api/monitors/start - 启动监控 6 | export async function POST(request: Request) { 7 | try { 8 | // 验证用户是否已登录 9 | const authError = await validateAuth(); 10 | if (authError) return authError; 11 | 12 | const { id } = await request.json(); 13 | 14 | if (!id) { 15 | return NextResponse.json( 16 | { error: '监控项ID为必填项' }, 17 | { status: 400 } 18 | ); 19 | } 20 | 21 | // 调度该监控项 22 | const result = await scheduleMonitor(id); 23 | 24 | if (result) { 25 | return NextResponse.json({ message: '监控启动成功' }); 26 | } else { 27 | return NextResponse.json( 28 | { error: '监控启动失败,该监控可能不存在或已被禁用' }, 29 | { status: 400 } 30 | ); 31 | } 32 | } catch (error) { 33 | console.error('启动监控失败:', error); 34 | return NextResponse.json( 35 | { error: '启动监控失败,请稍后重试' }, 36 | { status: 500 } 37 | ); 38 | } 39 | } -------------------------------------------------------------------------------- /src/app/api/settings/password/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { updateAdminPassword } from '@/lib/settings'; 3 | import { validateAuth } from '@/lib/auth-helpers'; 4 | 5 | // 更新管理员密码 6 | export async function POST(request: NextRequest) { 7 | try { 8 | // 验证用户是否已登录 9 | const authError = await validateAuth(); 10 | if (authError) return authError; 11 | 12 | const { userId, currentPassword, newPassword } = await request.json(); 13 | 14 | if (!userId || !currentPassword || !newPassword) { 15 | return NextResponse.json( 16 | { success: false, error: '缺少必要的字段' }, 17 | { status: 400 } 18 | ); 19 | } 20 | 21 | await updateAdminPassword(userId, currentPassword, newPassword); 22 | 23 | return NextResponse.json({ success: true }); 24 | } catch (error) { 25 | // 获取错误消息 26 | const message = error instanceof Error ? error.message : '更新密码失败'; 27 | 28 | console.error('更新密码失败:', error); 29 | return NextResponse.json( 30 | { success: false, error: message }, 31 | { status: 500 } 32 | ); 33 | } 34 | } -------------------------------------------------------------------------------- /prisma/migrations/20250426040543_add_monitor_notification/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "MonitorNotification" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "monitorId" TEXT NOT NULL, 5 | "notificationChannelId" TEXT NOT NULL, 6 | "enabled" BOOLEAN NOT NULL DEFAULT true, 7 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" DATETIME NOT NULL, 9 | CONSTRAINT "MonitorNotification_monitorId_fkey" FOREIGN KEY ("monitorId") REFERENCES "Monitor" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 10 | CONSTRAINT "MonitorNotification_notificationChannelId_fkey" FOREIGN KEY ("notificationChannelId") REFERENCES "NotificationChannel" ("id") ON DELETE CASCADE ON UPDATE CASCADE 11 | ); 12 | 13 | -- CreateIndex 14 | CREATE INDEX "MonitorNotification_monitorId_idx" ON "MonitorNotification"("monitorId"); 15 | 16 | -- CreateIndex 17 | CREATE INDEX "MonitorNotification_notificationChannelId_idx" ON "MonitorNotification"("notificationChannelId"); 18 | 19 | -- CreateIndex 20 | CREATE UNIQUE INDEX "MonitorNotification_monitorId_notificationChannelId_key" ON "MonitorNotification"("monitorId", "notificationChannelId"); 21 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/monitors/reorder/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { monitorOperations } from '@/lib/db'; 3 | import { validateAuth } from '@/lib/auth-helpers'; 4 | 5 | // PUT /api/monitors/reorder - 更新监控项排序 6 | export async function PUT(request: NextRequest) { 7 | try { 8 | // 验证用户是否已登录 9 | const authError = await validateAuth(); 10 | if (authError) return authError; 11 | 12 | const data = await request.json(); 13 | const { updates } = data; 14 | 15 | if (!Array.isArray(updates) || updates.length === 0) { 16 | return NextResponse.json( 17 | { error: '排序数据格式错误' }, 18 | { status: 400 } 19 | ); 20 | } 21 | 22 | // 验证每个更新项 23 | for (const update of updates) { 24 | if (!update.id || typeof update.displayOrder !== 'number') { 25 | return NextResponse.json( 26 | { error: '排序数据格式错误' }, 27 | { status: 400 } 28 | ); 29 | } 30 | } 31 | 32 | // 批量更新排序 33 | await monitorOperations.updateMonitorsOrder(updates); 34 | 35 | return NextResponse.json({ 36 | message: '排序更新成功', 37 | updates 38 | }); 39 | } catch (error) { 40 | console.error('更新监控项排序失败:', error); 41 | return NextResponse.json( 42 | { error: '更新监控项排序失败,请稍后重试' }, 43 | { status: 500 } 44 | ); 45 | } 46 | } -------------------------------------------------------------------------------- /src/app/dashboard/login-records/page.tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession } from 'next-auth'; 2 | import { buildAuthOptions } from '@/app/api/auth/[...nextauth]/route'; 3 | import { redirect } from 'next/navigation'; 4 | import Link from 'next/link'; 5 | import LoginRecordsTable from './login-records-table'; 6 | 7 | export const metadata = { 8 | title: '登录记录 - 酷监控', 9 | description: '查看您的登录记录和登录历史' 10 | }; 11 | 12 | export default async function LoginRecordsPage() { 13 | // 检查是否已登录 14 | const authOptions = await buildAuthOptions(); 15 | const session = await getServerSession(authOptions); 16 | 17 | if (!session) { 18 | redirect('/auth/login'); 19 | } 20 | 21 | return ( 22 |
23 |
24 |

登录记录

25 | 29 | 30 | 返回仪表盘 31 | 32 |
33 | 34 |

35 | 查看您最近的登录历史,包括登录时间、IP地址和设备信息。 36 |

37 | 38 | 39 |
40 | ); 41 | } -------------------------------------------------------------------------------- /src/lib/startup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 系统启动脚本 3 | * 在服务器启动时自动初始化监控系统 4 | */ 5 | import { upgradeDatabaseIfNeeded } from './database-upgrader'; 6 | 7 | /** 8 | * 强制初始化系统 9 | * 用于系统启动时,确保监控系统被初始化 10 | */ 11 | export async function forceInitSystem() { 12 | try { 13 | console.log('系统启动时强制初始化监控系统...'); 14 | 15 | // 执行数据库迁移 16 | try { 17 | console.log('开始执行数据库升级...'); 18 | await upgradeDatabaseIfNeeded(); 19 | } catch (error) { 20 | console.error('数据库升级失败:', error); 21 | } 22 | 23 | // 启动数据清理任务 24 | try { 25 | const { startDataCleanupJob } = await import('./monitors/data-cleaner'); 26 | startDataCleanupJob(); 27 | console.log('数据清理定时任务已启动'); 28 | } catch (error) { 29 | console.error('启动数据清理任务失败:', error); 30 | } 31 | 32 | try { 33 | // 动态导入监控调度器 34 | const { resetAllMonitors } = await import('./monitors/scheduler'); 35 | 36 | // 重置所有监控项 37 | const count = await resetAllMonitors(); 38 | 39 | console.log(`系统启动初始化成功,已启动 ${count} 个监控项`); 40 | } catch (error) { 41 | console.error('系统启动初始化失败:', error); 42 | } 43 | 44 | console.log(`监控系统启动初始化完成`); 45 | return true; 46 | } catch (error) { 47 | console.error('监控系统启动初始化处理失败:', error); 48 | return false; 49 | } 50 | } 51 | 52 | 53 | // 导出标记,表示此模块已加载 54 | export const SYSTEM_INITIALIZED = true; -------------------------------------------------------------------------------- /src/app/api/status-pages/[id]/monitors/[monitorId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { prisma } from '@/lib/prisma'; 3 | import { getServerSession } from 'next-auth'; 4 | import { authOptions } from '@/lib/auth'; 5 | 6 | // 从状态页移除监控项 7 | export async function DELETE( 8 | request: NextRequest, 9 | { params }: { params: Promise<{ id: string; monitorId: string }> } 10 | ) { 11 | try { 12 | const { id, monitorId } = await params; 13 | 14 | const session = await getServerSession(authOptions); 15 | if (!session?.user) { 16 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 17 | } 18 | 19 | // 检查状态页是否存在且属于当前用户 20 | const statusPage = await prisma.statusPage.findFirst({ 21 | where: { 22 | id, 23 | createdById: session.user.id 24 | } 25 | }); 26 | 27 | if (!statusPage) { 28 | return NextResponse.json({ error: '状态页不存在' }, { status: 404 }); 29 | } 30 | 31 | // 删除监控项关联 32 | await prisma.statusPageMonitor.delete({ 33 | where: { 34 | statusPageId_monitorId: { 35 | statusPageId: id, 36 | monitorId 37 | } 38 | } 39 | }); 40 | 41 | return NextResponse.json({ message: '监控项移除成功' }); 42 | } catch (error) { 43 | console.error('移除监控项失败:', error); 44 | return NextResponse.json({ error: '移除监控项失败' }, { status: 500 }); 45 | } 46 | } -------------------------------------------------------------------------------- /src/app/dashboard/monitors/components/MonitorTypeSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from "react"; 2 | 3 | interface MonitorTypeSelectorProps { 4 | value: string; 5 | onChange: Dispatch>; 6 | } 7 | 8 | export function MonitorTypeSelector({ value, onChange }: MonitorTypeSelectorProps) { 9 | return ( 10 |
11 | 12 | 33 |
34 | ); 35 | } -------------------------------------------------------------------------------- /prisma/migrations/20250423015548_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Monitor" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "name" TEXT NOT NULL, 5 | "type" TEXT NOT NULL, 6 | "config" JSONB NOT NULL, 7 | "active" BOOLEAN NOT NULL DEFAULT true, 8 | "interval" INTEGER NOT NULL DEFAULT 60, 9 | "retries" INTEGER NOT NULL DEFAULT 0, 10 | "retryInterval" INTEGER NOT NULL DEFAULT 60, 11 | "resendInterval" INTEGER NOT NULL DEFAULT 0, 12 | "upsideDown" BOOLEAN NOT NULL DEFAULT false, 13 | "description" TEXT, 14 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 | "updatedAt" DATETIME NOT NULL, 16 | "createdById" TEXT, 17 | "lastCheckAt" DATETIME, 18 | "nextCheckAt" DATETIME, 19 | "lastStatus" INTEGER, 20 | CONSTRAINT "Monitor_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE 21 | ); 22 | 23 | -- CreateTable 24 | CREATE TABLE "MonitorStatus" ( 25 | "id" TEXT NOT NULL PRIMARY KEY, 26 | "monitorId" TEXT NOT NULL, 27 | "status" INTEGER NOT NULL, 28 | "message" TEXT, 29 | "ping" INTEGER, 30 | "timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 31 | CONSTRAINT "MonitorStatus_monitorId_fkey" FOREIGN KEY ("monitorId") REFERENCES "Monitor" ("id") ON DELETE CASCADE ON UPDATE CASCADE 32 | ); 33 | 34 | -- CreateIndex 35 | CREATE INDEX "MonitorStatus_monitorId_timestamp_idx" ON "MonitorStatus"("monitorId", "timestamp"); 36 | -------------------------------------------------------------------------------- /startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 确保数据目录存在 4 | mkdir -p /app/data 5 | 6 | # 复制环境变量文件(如果存在于数据目录) 7 | if [ -f /app/data/.env ]; then 8 | echo "在数据目录中发现.env文件,正在复制到应用目录..." 9 | cp /app/data/.env /app/.env 10 | echo ".env文件复制完成" 11 | fi 12 | 13 | # 检查是否强制升级 14 | if [ -f /app/data/.force-upgrade ]; then 15 | echo "检测到强制升级标志,将执行数据库升级..." 16 | # 创建升级标记文件 17 | touch /app/data/.db-upgrade-needed 18 | # 删除标志文件 19 | rm /app/data/.force-upgrade 20 | echo "升级标记已设置,应用启动时将执行数据库升级" 21 | fi 22 | 23 | # 检查数据库文件是否存在 24 | if [ ! -f /app/data/coolmonitor.db ]; then 25 | echo "数据库文件不存在,寻找预生成的模板..." 26 | 27 | # 查找模板数据库文件 28 | TEMPLATE_DB=$(find /app/prisma/template -name "*.db" | head -n 1) 29 | 30 | if [ -n "$TEMPLATE_DB" ]; then 31 | # 复制模板数据库到数据目录 32 | cp "$TEMPLATE_DB" /app/data/coolmonitor.db 33 | echo "数据库初始化完成" 34 | else 35 | echo "警告:未找到模板数据库,创建空数据库文件..." 36 | # 创建空数据库文件,让应用可以启动 37 | touch /app/data/coolmonitor.db 38 | echo "已创建空数据库文件,可能需要手动初始化" 39 | fi 40 | else 41 | echo "数据库文件已存在,无需初始化" 42 | fi 43 | 44 | # 检查是否需要创建.env文件(无论数据库是否存在) 45 | if [ ! -f /app/data/.env ]; then 46 | echo "创建.env文件到数据目录..." 47 | RANDOM_SECRET=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n') 48 | echo "# NextAuth.js 密钥" > /app/data/.env 49 | echo "NEXTAUTH_SECRET=$RANDOM_SECRET" >> /app/data/.env 50 | echo ".env文件已创建到数据目录" 51 | 52 | # 复制.env文件到应用目录 53 | cp /app/data/.env /app/.env 54 | echo ".env文件已复制到应用目录" 55 | fi 56 | 57 | # 启动应用 58 | echo "启动应用..." 59 | exec node server.js -------------------------------------------------------------------------------- /src/app/api/auth/register/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { createUser, hasAdminUser } from '@/lib/auth'; 3 | import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; 4 | 5 | export async function POST(req: Request) { 6 | try { 7 | const body = await req.json(); 8 | const { username, password } = body; 9 | 10 | if (!username || !password) { 11 | return NextResponse.json( 12 | { message: '请提供必要的注册信息' }, 13 | { status: 400 } 14 | ); 15 | } 16 | 17 | // 检查是否已有管理员账户 18 | const hasAdmin = await hasAdminUser(); 19 | 20 | // 如果已有管理员,禁止注册新用户 21 | if (hasAdmin) { 22 | return NextResponse.json( 23 | { message: '注册功能当前已禁用' }, 24 | { status: 403 } 25 | ); 26 | } 27 | 28 | // 创建用户,如果是首个用户则设为管理员 29 | const user = await createUser(null, username, password, !hasAdmin); 30 | 31 | return NextResponse.json( 32 | { 33 | message: '用户创建成功', 34 | user: { 35 | id: user.id, 36 | username, 37 | name: user.name, 38 | email: user.email, 39 | isAdmin: user.isAdmin, 40 | } 41 | }, 42 | { status: 201 } 43 | ); 44 | } catch (error: unknown) { 45 | if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') { 46 | return NextResponse.json( 47 | { message: '该账户名已被注册' }, 48 | { status: 409 } 49 | ); 50 | } 51 | 52 | console.error('注册错误:', error); 53 | return NextResponse.json( 54 | { message: '注册过程中发生错误' }, 55 | { status: 500 } 56 | ); 57 | } 58 | } -------------------------------------------------------------------------------- /src/app/api/settings/debug/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { prisma } from '@/lib/prisma'; 3 | import { SETTINGS_KEYS } from '@/lib/settings'; 4 | import { validateAuth } from '@/lib/auth-helpers'; 5 | 6 | // 调试API: 获取数据库中的原始设置值,开发环境中排查问题使用 7 | export async function GET() { 8 | // 验证用户是否已登录 9 | const authError = await validateAuth(); 10 | if (authError) return authError; 11 | 12 | // 生产环境禁用此API 13 | if (process.env.NODE_ENV === 'production') { 14 | return NextResponse.json( 15 | { error: 'This endpoint is disabled in production' }, 16 | { status: 403 } 17 | ); 18 | } 19 | 20 | try { 21 | // 获取所有系统配置项 22 | const allConfigs = await prisma.systemConfig.findMany(); 23 | 24 | // 获取代理相关的配置 25 | const proxyConfigs = allConfigs.filter(config => 26 | config.key === SETTINGS_KEYS.PROXY_ENABLED || 27 | config.key === SETTINGS_KEYS.PROXY_SERVER || 28 | config.key === SETTINGS_KEYS.PROXY_PORT || 29 | config.key === SETTINGS_KEYS.PROXY_USERNAME || 30 | config.key === SETTINGS_KEYS.PROXY_PASSWORD 31 | ); 32 | 33 | return NextResponse.json({ 34 | success: true, 35 | message: '仅用于开发环境调试', 36 | allConfigs, 37 | proxyConfigs, 38 | // 特别显示代理启用状态的原始值 39 | proxyEnabled: allConfigs.find(c => c.key === SETTINGS_KEYS.PROXY_ENABLED)?.value, 40 | proxyEnabledType: typeof allConfigs.find(c => c.key === SETTINGS_KEYS.PROXY_ENABLED)?.value 41 | }); 42 | } catch (error) { 43 | console.error('获取调试数据失败:', error); 44 | return NextResponse.json( 45 | { success: false, error: '获取调试数据失败' }, 46 | { status: 500 } 47 | ); 48 | } 49 | } -------------------------------------------------------------------------------- /src/components/settings/about-settings.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export function AboutSettings() { 4 | return ( 5 |
6 |

关于

7 | 8 |
9 |
10 | 11 |
12 | 13 |

酷监控

14 |

高颜值的监控工具

15 | 16 | 17 | 38 |
39 |
40 | ); 41 | } -------------------------------------------------------------------------------- /src/lib/monitors/status-recorder.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma'; 2 | 3 | import { generateCompactMessage } from '../utils/compact-message'; 4 | 5 | interface RecordStatusParams { 6 | monitorId: string; 7 | status: number; 8 | message: string; 9 | ping?: number; 10 | details?: Record; 11 | } 12 | 13 | export async function recordMonitorStatus(params: RecordStatusParams) { 14 | const { monitorId, status, message, ping } = params; 15 | 16 | try { 17 | // 使用Prisma默认的UUID生成,确保唯一性和稳定性 18 | const compactMessage = generateCompactMessage(status, message, ping); 19 | 20 | // 创建状态记录 21 | const record = await prisma.monitorStatus.create({ 22 | data: { 23 | monitorId, 24 | status, 25 | message: compactMessage, 26 | ping 27 | } 28 | }); 29 | 30 | // 更新监控项的最新状态 31 | await prisma.monitor.update({ 32 | where: { id: monitorId }, 33 | data: { 34 | lastCheckAt: new Date(), 35 | lastStatus: status 36 | } 37 | }); 38 | 39 | return record; 40 | } catch (error) { 41 | console.error('记录监控状态失败:', error); 42 | throw error; 43 | } 44 | } 45 | 46 | // 清理历史记录 47 | export async function cleanupStatusHistory(days: number = 30) { 48 | const cutoffDate = new Date(); 49 | cutoffDate.setDate(cutoffDate.getDate() - days); 50 | 51 | try { 52 | const result = await prisma.monitorStatus.deleteMany({ 53 | where: { 54 | timestamp: { 55 | lt: cutoffDate 56 | } 57 | } 58 | }); 59 | 60 | console.log(`已清理 ${result.count} 条历史记录`); 61 | return result.count; 62 | } catch (error) { 63 | console.error('清理历史记录失败:', error); 64 | throw error; 65 | } 66 | } -------------------------------------------------------------------------------- /src/app/api/settings/notifications/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { 3 | getNotificationChannels, 4 | createNotificationChannel 5 | } from '@/lib/settings'; 6 | import { validateAuth } from '@/lib/auth-helpers'; 7 | 8 | // 获取所有通知渠道 9 | export async function GET() { 10 | try { 11 | // 验证用户是否已登录 12 | const authError = await validateAuth(); 13 | if (authError) return authError; 14 | 15 | const channels = await getNotificationChannels(); 16 | return NextResponse.json({ success: true, data: channels }); 17 | } catch (error) { 18 | console.error('获取通知渠道列表失败:', error); 19 | return NextResponse.json( 20 | { success: false, error: '获取通知渠道列表失败' }, 21 | { status: 500 } 22 | ); 23 | } 24 | } 25 | 26 | // 创建新通知渠道 27 | export async function POST(request: NextRequest) { 28 | try { 29 | // 验证用户是否已登录 30 | const authError = await validateAuth(); 31 | if (authError) return authError; 32 | 33 | const body = await request.json(); 34 | 35 | if (!body || !body.name || !body.type || !body.config) { 36 | return NextResponse.json( 37 | { success: false, error: '缺少必要的字段' }, 38 | { status: 400 } 39 | ); 40 | } 41 | 42 | const newChannel = await createNotificationChannel({ 43 | name: body.name, 44 | type: body.type, 45 | enabled: body.enabled !== false, 46 | defaultForNewMonitors: body.defaultForNewMonitors !== false, 47 | config: body.config 48 | }); 49 | 50 | return NextResponse.json({ success: true, data: newChannel }); 51 | } catch (error) { 52 | console.error('创建通知渠道失败:', error); 53 | return NextResponse.json( 54 | { success: false, error: '创建通知渠道失败' }, 55 | { status: 500 } 56 | ); 57 | } 58 | } -------------------------------------------------------------------------------- /src/app/api/user/login-records/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { getServerSession } from 'next-auth'; 3 | import { buildAuthOptions } from '@/app/api/auth/[...nextauth]/route'; 4 | import { getUserLoginRecords } from '@/lib/auth'; 5 | 6 | export async function GET(req: NextRequest) { 7 | try { 8 | // 验证用户是否已登录 9 | const authOptions = await buildAuthOptions(); 10 | const session = await getServerSession(authOptions); 11 | 12 | if (!session || !session.user) { 13 | return NextResponse.json( 14 | { error: '未授权的请求' }, 15 | { status: 401 } 16 | ); 17 | } 18 | 19 | // 获取分页参数 20 | const searchParams = req.nextUrl.searchParams; 21 | const limit = parseInt(searchParams.get('limit') || '20'); 22 | const page = parseInt(searchParams.get('page') || '1'); 23 | const offset = (page - 1) * limit; 24 | 25 | // 获取登录记录 26 | const { records, total } = await getUserLoginRecords(session.user.id, limit, offset); 27 | 28 | // 格式化返回数据 29 | const formattedRecords = records.map(record => ({ 30 | id: record.id, 31 | ipAddress: record.ipAddress || '未知', 32 | userAgent: record.userAgent || '未知', 33 | success: record.success, 34 | time: record.createdAt, 35 | })); 36 | 37 | return NextResponse.json({ 38 | success: true, 39 | data: { 40 | records: formattedRecords, 41 | pagination: { 42 | total, 43 | currentPage: page, 44 | pageSize: limit, 45 | totalPages: Math.ceil(total / limit) 46 | } 47 | } 48 | }); 49 | } catch (error) { 50 | console.error('获取登录记录失败:', error); 51 | return NextResponse.json( 52 | { error: '获取登录记录失败' }, 53 | { status: 500 } 54 | ); 55 | } 56 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { Toaster } from "react-hot-toast"; 3 | import "@fortawesome/fontawesome-free/css/all.min.css"; 4 | import AuthContext from "@/context/AuthContext"; 5 | import { ThemeProvider } from "@/components/theme-provider"; 6 | import Script from "next/script"; 7 | 8 | // 系统启动现在通过 instrumentation.ts 自动初始化 9 | 10 | export default async function RootLayout({ 11 | children, 12 | }: Readonly<{ 13 | children: React.ReactNode; 14 | }>) { 15 | // suppressHydrationWarning 说明: 16 | // - 允许我们在客户端通过脚本/ThemeProvider 动态切换 上的 class(dark/light), 17 | // - 避免因服务端与客户端首帧 class 不一致而触发 Hydration 警告。 18 | return ( 19 | 20 | 21 | 34 | 35 | 36 | 37 | 38 | {children} 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coolmonitor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "npx prisma generate && next dev --turbopack ", 7 | "build": "npx prisma generate && next build --no-lint", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "vitest run", 11 | "test:watch": "vitest", 12 | "test:coverage": "vitest run --coverage" 13 | }, 14 | "dependencies": { 15 | "@types/nodemailer": "^6.4.17", 16 | "axios": "^1.8.4", 17 | "croner": "^9.0.0", 18 | "echarts": "^5.6.0", 19 | "mysql2": "^3.14.0", 20 | "next": "15.3.6", 21 | "nodemailer": "^6.10.1", 22 | "pg": "^8.15.1", 23 | "ping": "^0.4.4", 24 | "react": "^19.0.1", 25 | "react-dom": "^19.0.1", 26 | "react-hot-toast": "^2.5.2", 27 | "redis": "^4.7.0", 28 | "ssl-checker": "^2.0.10", 29 | "undici": "^6.21.2", 30 | "uuid": "^11.1.0", 31 | "xlsx": "^0.18.5" 32 | }, 33 | "devDependencies": { 34 | "@eslint/eslintrc": "^3", 35 | "@fortawesome/fontawesome-free": "^6.7.2", 36 | "@prisma/client": "^6.6.0", 37 | "@testing-library/jest-dom": "^5.16.5", 38 | "@testing-library/react": "^16.3.0", 39 | "@testing-library/user-event": "^14.6.1", 40 | "@types/bcryptjs": "^2.4.6", 41 | "@types/node": "^20", 42 | "@types/pg": "^8.11.13", 43 | "@types/react": "^19", 44 | "@types/react-dom": "^19", 45 | "@types/uuid": "^10.0.0", 46 | "@vitejs/plugin-react": "^4.4.1", 47 | "@vitest/coverage-v8": "^3.1.2", 48 | "autoprefixer": "^10.4.21", 49 | "bcryptjs": "^3.0.2", 50 | "eslint": "^9", 51 | "eslint-config-next": "15.3.6", 52 | "jsdom": "^26.1.0", 53 | "next-auth": "^4.24.11", 54 | "postcss": "^8.5.3", 55 | "prisma": "^6.6.0", 56 | "tailwindcss": "^3.4.1", 57 | "typescript": "^5", 58 | "vitest": "^3.1.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/lib/monitors/data-cleaner.ts: -------------------------------------------------------------------------------- 1 | import { Cron } from 'croner'; 2 | import { prisma } from '../prisma'; 3 | import { getSetting, SETTINGS_KEYS } from '../settings'; 4 | 5 | // 数据清理任务,每天凌晨3点执行一次 6 | let cleanupJob: Cron | null = null; 7 | 8 | // 启动数据清理定时任务 9 | export function startDataCleanupJob() { 10 | if (cleanupJob) { 11 | cleanupJob.stop(); 12 | } 13 | 14 | // 每天凌晨3点运行 15 | cleanupJob = new Cron('0 3 * * *', { 16 | name: 'data-cleanup-job', 17 | protect: true, 18 | }, async () => { 19 | try { 20 | await cleanupOldData(); 21 | } catch (error) { 22 | console.error('数据清理任务执行失败:', error); 23 | } 24 | }); 25 | 26 | console.log('数据清理定时任务已启动'); 27 | return cleanupJob; 28 | } 29 | 30 | // 停止数据清理定时任务 31 | export function stopDataCleanupJob() { 32 | if (cleanupJob) { 33 | cleanupJob.stop(); 34 | cleanupJob = null; 35 | console.log('数据清理定时任务已停止'); 36 | return true; 37 | } 38 | return false; 39 | } 40 | 41 | // 立即执行数据清理 42 | export async function cleanupOldData(): Promise { 43 | try { 44 | // 从设置中获取数据保留天数 45 | const retentionDaysStr = await getSetting(SETTINGS_KEYS.DATA_RETENTION_DAYS); 46 | const retentionDays = parseInt(retentionDaysStr) || 90; 47 | 48 | // 计算截止日期 49 | const cutoffDate = new Date(); 50 | cutoffDate.setDate(cutoffDate.getDate() - retentionDays); 51 | 52 | // 删除截止日期之前的所有监控状态记录 53 | const result = await prisma.$executeRaw` 54 | DELETE FROM "MonitorStatus" 55 | WHERE "timestamp" < ${cutoffDate} 56 | `; 57 | 58 | console.log(`数据清理完成: 已删除 ${result} 条过期的监控记录 (保留期: ${retentionDays}天)`); 59 | return Number(result); 60 | } catch (error) { 61 | console.error('执行数据清理失败:', error); 62 | throw error; 63 | } 64 | } 65 | 66 | // 手动触发数据清理 67 | export async function triggerManualCleanup(): Promise { 68 | try { 69 | return await cleanupOldData(); 70 | } catch (error) { 71 | console.error('手动数据清理失败:', error); 72 | return 0; 73 | } 74 | } -------------------------------------------------------------------------------- /prisma/migrations/20250729060305_add_status_page/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "LoginRecord" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "userId" TEXT NOT NULL, 5 | "ipAddress" TEXT, 6 | "userAgent" TEXT, 7 | "success" BOOLEAN NOT NULL DEFAULT true, 8 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | CONSTRAINT "LoginRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 10 | ); 11 | 12 | -- CreateTable 13 | CREATE TABLE "StatusPage" ( 14 | "id" TEXT NOT NULL PRIMARY KEY, 15 | "name" TEXT NOT NULL, 16 | "slug" TEXT NOT NULL, 17 | "title" TEXT NOT NULL, 18 | "isPublic" BOOLEAN NOT NULL DEFAULT true, 19 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 20 | "updatedAt" DATETIME NOT NULL, 21 | "createdById" TEXT, 22 | CONSTRAINT "StatusPage_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE 23 | ); 24 | 25 | -- CreateTable 26 | CREATE TABLE "StatusPageMonitor" ( 27 | "statusPageId" TEXT NOT NULL, 28 | "monitorId" TEXT NOT NULL, 29 | "displayName" TEXT, 30 | "order" INTEGER NOT NULL DEFAULT 0, 31 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 32 | 33 | PRIMARY KEY ("statusPageId", "monitorId"), 34 | CONSTRAINT "StatusPageMonitor_statusPageId_fkey" FOREIGN KEY ("statusPageId") REFERENCES "StatusPage" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 35 | CONSTRAINT "StatusPageMonitor_monitorId_fkey" FOREIGN KEY ("monitorId") REFERENCES "Monitor" ("id") ON DELETE CASCADE ON UPDATE CASCADE 36 | ); 37 | 38 | -- CreateIndex 39 | CREATE INDEX "LoginRecord_userId_createdAt_idx" ON "LoginRecord"("userId", "createdAt"); 40 | 41 | -- CreateIndex 42 | CREATE UNIQUE INDEX "StatusPage_slug_key" ON "StatusPage"("slug"); 43 | 44 | -- CreateIndex 45 | CREATE INDEX "StatusPage_slug_idx" ON "StatusPage"("slug"); 46 | 47 | -- CreateIndex 48 | CREATE INDEX "StatusPageMonitor_statusPageId_order_idx" ON "StatusPageMonitor"("statusPageId", "order"); 49 | -------------------------------------------------------------------------------- /src/lib/monitors/types.ts: -------------------------------------------------------------------------------- 1 | // 监控状态常量 2 | export const MONITOR_STATUS = { 3 | DOWN: 0, 4 | UP: 1, 5 | PENDING: 2 6 | }; 7 | 8 | // 监控错误消息 9 | export const ERROR_MESSAGES = { 10 | CONNECTION_REFUSED: '连接被拒绝', 11 | TIMEOUT: '连接超时', 12 | HOST_NOT_FOUND: '无法解析主机名', 13 | INVALID_STATUS: '状态码不符合预期', 14 | KEYWORD_NOT_FOUND: '未找到关键词', 15 | NETWORK_ERROR: '网络错误', 16 | DATABASE_ERROR: '数据库错误', 17 | AUTHENTICATION_FAILED: '身份验证失败', 18 | UNKNOWN_ERROR: '未知错误' 19 | }; 20 | 21 | // 定义监控配置接口 22 | export interface MonitorHttpConfig { 23 | url: string; 24 | httpMethod?: string; 25 | statusCodes?: string; 26 | maxRedirects?: number; 27 | ignoreTls?: boolean; 28 | requestBody?: string; 29 | requestHeaders?: string | Record; 30 | connectTimeout?: number; // HTTP请求连接超时时间(秒),默认10秒 31 | monitorId?: string; // 监控项ID,用于发送通知 32 | monitorName?: string; // 监控项名称,用于发送通知 33 | notifyCertExpiry?: boolean; // 是否启用证书到期通知 34 | certWarning?: string; // 临时存储证书警告信息 35 | retries?: number; // 重试次数 36 | retryInterval?: number; // 重试间隔(秒) 37 | } 38 | 39 | export interface MonitorKeywordConfig extends MonitorHttpConfig { 40 | keyword: string; 41 | } 42 | 43 | export interface MonitorPortConfig { 44 | hostname: string; 45 | port: number | string; 46 | retries?: number; // 重试次数 47 | retryInterval?: number; // 重试间隔(秒) 48 | } 49 | 50 | export interface MonitorDatabaseConfig extends MonitorPortConfig { 51 | username?: string; 52 | password?: string; 53 | database?: string; 54 | query?: string; 55 | } 56 | 57 | export interface MonitorPushConfig { 58 | token: string; 59 | lastPushTime?: string | number | Date; 60 | pushInterval?: number; 61 | } 62 | 63 | export interface MonitorIcmpConfig { 64 | hostname: string; 65 | packetCount?: number; // ping包数量,默认4 66 | maxPacketLoss?: number; // 最大允许丢包率(%),默认0 67 | maxResponseTime?: number; // 最大允许响应时间(ms) 68 | retries?: number; // 重试次数 69 | retryInterval?: number; // 重试间隔(秒) 70 | } 71 | 72 | // 监控检查结果接口 73 | export interface MonitorCheckResult { 74 | status: number; 75 | message: string; 76 | ping: number | null; 77 | certificateDaysRemaining?: number; 78 | } -------------------------------------------------------------------------------- /src/lib/monitors/checker-push.ts: -------------------------------------------------------------------------------- 1 | import { MonitorCheckResult, MonitorPushConfig, MONITOR_STATUS, ERROR_MESSAGES } from './types'; 2 | 3 | export async function checkPush(config: MonitorPushConfig): Promise { 4 | try { 5 | // 兼容性处理:确保配置存在且至少包含必要字段 6 | if (!config || typeof config !== 'object') { 7 | return { 8 | status: MONITOR_STATUS.DOWN, 9 | message: '配置无效: 缺少必要的配置信息', 10 | ping: null 11 | }; 12 | } 13 | 14 | const { lastPushTime, pushInterval } = config; 15 | 16 | // 检查最后一次推送时间 17 | if (!lastPushTime) { 18 | return { 19 | status: MONITOR_STATUS.PENDING, 20 | message: '等待推送', 21 | ping: null 22 | }; 23 | } 24 | 25 | const lastPush = new Date(lastPushTime).getTime(); 26 | const currentTime = Date.now(); 27 | 28 | // 检查lastPush是否是一个有效的时间戳 29 | if (isNaN(lastPush)) { 30 | return { 31 | status: MONITOR_STATUS.PENDING, 32 | message: '推送时间格式无效,等待新的推送', 33 | ping: null 34 | }; 35 | } 36 | 37 | // 计算时间差(毫秒) 38 | const timeDiff = currentTime - lastPush; 39 | 40 | // 获取允许的时间间隔(秒转毫秒) 41 | const interval = (pushInterval || 60) * 1000; 42 | 43 | // 如果最后推送时间在允许的时间间隔内,则认为服务正常 44 | if (timeDiff <= interval) { 45 | return { 46 | status: MONITOR_STATUS.UP, 47 | message: `最近推送时间: ${new Date(lastPush).toLocaleString()}`, 48 | ping: null 49 | }; 50 | } else { 51 | // 计算超时时间 52 | const timeoutSeconds = Math.floor(timeDiff / 1000); 53 | const minutes = Math.floor(timeoutSeconds / 60); 54 | const seconds = timeoutSeconds % 60; 55 | 56 | const timeoutMessage = minutes > 0 57 | ? `超时 ${minutes} 分 ${seconds} 秒` 58 | : `超时 ${seconds} 秒`; 59 | 60 | return { 61 | status: MONITOR_STATUS.DOWN, 62 | message: `推送超时: ${timeoutMessage}`, 63 | ping: null 64 | }; 65 | } 66 | } catch (error) { 67 | return { 68 | status: MONITOR_STATUS.DOWN, 69 | message: error instanceof Error ? error.message : ERROR_MESSAGES.UNKNOWN_ERROR, 70 | ping: null 71 | }; 72 | } 73 | } -------------------------------------------------------------------------------- /src/lib/monitors/utils.ts: -------------------------------------------------------------------------------- 1 | import { ERROR_MESSAGES } from './types'; 2 | 3 | // 辅助函数:检查HTTP状态码是否符合预期 4 | export function checkStatusCode(statusCode: number, expectedStatusCodes: string): boolean { 5 | // 支持多种格式:200-299, 200,201,202, 200 6 | const statusParts = expectedStatusCodes.split(','); 7 | 8 | for (const part of statusParts) { 9 | const trimmedPart = part.trim(); 10 | 11 | // 范围表示法,如 200-299 12 | if (trimmedPart.includes('-')) { 13 | const [min, max] = trimmedPart.split('-').map(s => parseInt(s)); 14 | if (statusCode >= min && statusCode <= max) { 15 | return true; 16 | } 17 | } 18 | // 单个状态码,如 200 19 | else if (parseInt(trimmedPart) === statusCode) { 20 | return true; 21 | } 22 | } 23 | 24 | return false; 25 | } 26 | 27 | // 辅助函数:获取网络错误的可读消息 28 | export function getNetworkErrorMessage(error: unknown): string { 29 | const errorMessage = error instanceof Error ? error.message : ''; 30 | 31 | if (errorMessage.includes('ECONNREFUSED')) { 32 | return ERROR_MESSAGES.CONNECTION_REFUSED; 33 | } else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('timeout')) { 34 | return ERROR_MESSAGES.TIMEOUT; 35 | } else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) { 36 | return ERROR_MESSAGES.HOST_NOT_FOUND; 37 | } else { 38 | return `${ERROR_MESSAGES.NETWORK_ERROR}: ${errorMessage}`; 39 | } 40 | } 41 | 42 | /** 43 | * 格式化时间为 YYYY-MM-DD HH:mm:ss 格式 44 | * @param date 日期对象或时间戳 45 | * @returns 格式化后的时间字符串 46 | */ 47 | export function formatDateTime(date?: Date | number | string): string { 48 | const d = date ? new Date(date) : new Date(); 49 | 50 | // 转换为北京时间 51 | const beijingTime = new Date(d.getTime() + (d.getTimezoneOffset() * 60000) + (8 * 3600000)); 52 | 53 | const year = beijingTime.getFullYear(); 54 | const month = String(beijingTime.getMonth() + 1).padStart(2, '0'); 55 | const day = String(beijingTime.getDate()).padStart(2, '0'); 56 | const hours = String(beijingTime.getHours()).padStart(2, '0'); 57 | const minutes = String(beijingTime.getMinutes()).padStart(2, '0'); 58 | const seconds = String(beijingTime.getSeconds()).padStart(2, '0'); 59 | 60 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; 61 | } -------------------------------------------------------------------------------- /prisma/migrations/20250807031821_add_monitor_groups/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "MonitorGroup" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "name" TEXT NOT NULL, 5 | "description" TEXT, 6 | "color" TEXT, 7 | "displayOrder" INTEGER, 8 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "updatedAt" DATETIME NOT NULL, 10 | "createdById" TEXT, 11 | CONSTRAINT "MonitorGroup_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE 12 | ); 13 | 14 | -- RedefineTables 15 | PRAGMA defer_foreign_keys=ON; 16 | PRAGMA foreign_keys=OFF; 17 | CREATE TABLE "new_Monitor" ( 18 | "id" TEXT NOT NULL PRIMARY KEY, 19 | "name" TEXT NOT NULL, 20 | "type" TEXT NOT NULL, 21 | "config" JSONB NOT NULL, 22 | "active" BOOLEAN NOT NULL DEFAULT true, 23 | "interval" INTEGER NOT NULL DEFAULT 60, 24 | "retries" INTEGER NOT NULL DEFAULT 0, 25 | "retryInterval" INTEGER NOT NULL DEFAULT 60, 26 | "resendInterval" INTEGER NOT NULL DEFAULT 0, 27 | "upsideDown" BOOLEAN NOT NULL DEFAULT false, 28 | "description" TEXT, 29 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 30 | "updatedAt" DATETIME NOT NULL, 31 | "createdById" TEXT, 32 | "lastCheckAt" DATETIME, 33 | "nextCheckAt" DATETIME, 34 | "lastStatus" INTEGER, 35 | "displayOrder" INTEGER, 36 | "groupId" TEXT, 37 | CONSTRAINT "Monitor_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, 38 | CONSTRAINT "Monitor_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MonitorGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE 39 | ); 40 | INSERT INTO "new_Monitor" ("active", "config", "createdAt", "createdById", "description", "displayOrder", "id", "interval", "lastCheckAt", "lastStatus", "name", "nextCheckAt", "resendInterval", "retries", "retryInterval", "type", "updatedAt", "upsideDown") SELECT "active", "config", "createdAt", "createdById", "description", "displayOrder", "id", "interval", "lastCheckAt", "lastStatus", "name", "nextCheckAt", "resendInterval", "retries", "retryInterval", "type", "updatedAt", "upsideDown" FROM "Monitor"; 41 | DROP TABLE "Monitor"; 42 | ALTER TABLE "new_Monitor" RENAME TO "Monitor"; 43 | PRAGMA foreign_keys=ON; 44 | PRAGMA defer_foreign_keys=OFF; 45 | -------------------------------------------------------------------------------- /src/app/api/status-pages/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { prisma } from '@/lib/prisma'; 3 | import { getServerSession } from 'next-auth'; 4 | import { authOptions } from '@/lib/auth'; 5 | 6 | // 获取状态页列表 7 | export async function GET() { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | if (!session?.user) { 11 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 12 | } 13 | 14 | const statusPages = await prisma.statusPage.findMany({ 15 | where: { 16 | createdById: session.user.id 17 | }, 18 | include: { 19 | monitors: { 20 | include: { 21 | monitor: true 22 | }, 23 | orderBy: { 24 | order: 'asc' 25 | } 26 | } 27 | }, 28 | orderBy: { 29 | createdAt: 'desc' 30 | } 31 | }); 32 | 33 | return NextResponse.json(statusPages); 34 | } catch (error) { 35 | console.error('获取状态页列表失败:', error); 36 | return NextResponse.json({ error: '获取状态页列表失败' }, { status: 500 }); 37 | } 38 | } 39 | 40 | // 创建状态页 41 | export async function POST(request: NextRequest) { 42 | try { 43 | const session = await getServerSession(authOptions); 44 | if (!session?.user) { 45 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 46 | } 47 | 48 | const body = await request.json(); 49 | const { name, slug, title, isPublic = true } = body; 50 | 51 | if (!name || !slug || !title) { 52 | return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); 53 | } 54 | 55 | // 检查slug是否已存在 56 | const existingStatusPage = await prisma.statusPage.findUnique({ 57 | where: { slug } 58 | }); 59 | 60 | if (existingStatusPage) { 61 | return NextResponse.json({ error: 'URL标识符已存在' }, { status: 400 }); 62 | } 63 | 64 | const statusPage = await prisma.statusPage.create({ 65 | data: { 66 | name, 67 | slug, 68 | title, 69 | isPublic, 70 | createdById: session.user.id 71 | } 72 | }); 73 | 74 | return NextResponse.json(statusPage, { status: 201 }); 75 | } catch (error) { 76 | console.error('创建状态页失败:', error); 77 | return NextResponse.json({ error: '创建状态页失败' }, { status: 500 }); 78 | } 79 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | darkMode: 'class', 5 | content: [ 6 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 8 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 9 | ], 10 | safelist: [ 11 | 'bg-primary/5', 12 | 'bg-primary/10', 13 | 'bg-primary/20', 14 | 'bg-primary/30', 15 | 'text-primary', 16 | 'text-success', 17 | 'text-warning', 18 | 'text-error', 19 | 'border-primary/10', 20 | 'border-primary/15', 21 | 'border-primary/20', 22 | 'border-primary/25', 23 | 'border-primary/30', 24 | 'text-foreground/50', 25 | 'text-foreground/70', 26 | 'text-foreground/80', 27 | 'text-foreground/90', 28 | 'hover:bg-primary/5', 29 | 'hover:bg-primary/10', 30 | 'hover:opacity-90', 31 | 'divide-primary/10', 32 | 'bg-success/20', 33 | 'bg-error/20', 34 | 'bg-warning/20', 35 | 'bg-foreground/20', 36 | ], 37 | theme: { 38 | extend: { 39 | colors: { 40 | primary: 'var(--primary)', 41 | secondary: 'var(--secondary)', 42 | success: 'var(--success)', 43 | warning: 'var(--warning)', 44 | error: 'var(--error)', 45 | 'light-bg': 'var(--light-bg)', 46 | 'light-card': 'var(--light-card)', 47 | 'light-nav': 'var(--light-nav)', 48 | 'light-input': 'var(--light-input)', 49 | 'dark-bg': 'var(--dark-bg)', 50 | 'dark-card': 'var(--dark-card)', 51 | 'dark-nav': 'var(--dark-nav)', 52 | 'dark-input': 'var(--dark-input)', 53 | foreground: { 54 | DEFAULT: 'var(--dark-text-primary)', 55 | }, 56 | 'light-text-primary': 'var(--light-text-primary)', 57 | 'light-text-secondary': 'var(--light-text-secondary)', 58 | 'light-text-tertiary': 'var(--light-text-tertiary)', 59 | }, 60 | borderRadius: { 61 | 'none': '0px', 62 | 'sm': '4px', 63 | DEFAULT: '8px', 64 | 'md': '12px', 65 | 'lg': '16px', 66 | 'xl': '20px', 67 | '2xl': '24px', 68 | '3xl': '32px', 69 | 'full': '9999px', 70 | 'button': '0.375rem' 71 | }, 72 | boxShadow: { 73 | 'card': '0 4px 8px rgba(0, 0, 0, 0.1)', 74 | 'card-dark': '0 4px 8px rgba(0, 0, 0, 0.25)', 75 | }, 76 | }, 77 | }, 78 | plugins: [], 79 | }; 80 | 81 | export default config; -------------------------------------------------------------------------------- /src/components/monitor-history-item.tsx: -------------------------------------------------------------------------------- 1 | // 监控历史项组件 2 | import Link from 'next/link'; 3 | 4 | export type MonitorStatus = "正常" | "故障" | "维护" | "未知" | "暂停"; 5 | 6 | export interface MonitorHistoryItemProps { 7 | id: number; 8 | monitorId: string; 9 | monitorName: string; 10 | status: MonitorStatus; 11 | timestamp: string; 12 | message: string; 13 | duration: string; 14 | } 15 | 16 | // 获取监控项状态点样式 17 | export const getStatusDotClass = (status: MonitorStatus) => { 18 | switch (status) { 19 | case "正常": 20 | return "bg-success"; 21 | case "故障": 22 | return "bg-error"; 23 | case "维护": 24 | return "bg-primary"; 25 | case "未知": 26 | return "bg-warning"; 27 | case "暂停": 28 | return "bg-foreground/50"; 29 | default: 30 | return "bg-foreground/50"; 31 | } 32 | }; 33 | 34 | // 获取状态文字样式 35 | export const getStatusClass = (status: MonitorStatus) => { 36 | switch (status) { 37 | case "正常": 38 | return "bg-success/20 text-success"; 39 | case "故障": 40 | return "bg-error/20 text-error"; 41 | case "维护": 42 | return "bg-primary/20 text-primary"; 43 | case "未知": 44 | return "bg-warning/20 text-warning"; 45 | case "暂停": 46 | return "bg-foreground/20 text-foreground/50"; 47 | default: 48 | return "bg-foreground/20 text-foreground/50"; 49 | } 50 | }; 51 | 52 | export default function MonitorHistoryItem({ monitorId, monitorName, status, timestamp, message, duration }: Omit) { 53 | return ( 54 | 55 | 56 |
57 |
58 | 59 | {status} 60 | 61 |
62 | 63 | 64 | 68 | {monitorName} 69 | 70 | 71 | {timestamp} 72 | {duration} 73 | {message} 74 | 75 | ); 76 | } -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | branches: 11 | - "master" 12 | 13 | jobs: 14 | docker: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v2 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v2 25 | 26 | - name: Login to DockerHub 27 | if: github.event_name != 'pull_request' 28 | uses: docker/login-action@v2 29 | with: 30 | username: ${{ secrets.DOCKERHUB_USERNAME }} 31 | password: ${{ secrets.DOCKERHUB_TOKEN }} 32 | 33 | - name: Extract metadata (tags, labels) for Docker 34 | id: meta 35 | uses: docker/metadata-action@v4 36 | with: 37 | images: star7th/coolmonitor 38 | tags: | 39 | type=ref,event=branch 40 | type=ref,event=pr 41 | type=semver,pattern={{version}} 42 | type=semver,pattern={{major}}.{{minor}} 43 | type=sha,format=short 44 | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} 45 | 46 | - name: Build and push 47 | uses: docker/build-push-action@v4 48 | with: 49 | context: . 50 | push: ${{ github.event_name != 'pull_request' }} 51 | tags: ${{ steps.meta.outputs.tags }} 52 | labels: ${{ steps.meta.outputs.labels }} 53 | cache-from: type=gha 54 | cache-to: type=gha,mode=max 55 | 56 | - name: Extract metadata for ARM Docker 57 | id: meta-arm 58 | if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request' 59 | uses: docker/metadata-action@v4 60 | with: 61 | images: star7th/coolmonitor 62 | tags: | 63 | type=raw,value=arm-latest 64 | 65 | - name: Build and push ARM version 66 | if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request' 67 | uses: docker/build-push-action@v4 68 | with: 69 | context: . 70 | file: ./Dockerfile-ARM 71 | push: true 72 | platforms: linux/arm64 73 | tags: ${{ steps.meta-arm.outputs.tags }} 74 | labels: ${{ steps.meta-arm.outputs.labels }} 75 | cache-from: type=gha 76 | cache-to: type=gha,mode=max 77 | 78 | -------------------------------------------------------------------------------- /src/app/api/system/init/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getOrCreateJwtSecret } from '@/lib/system-config'; 3 | import { hasAdminUser, createUser } from '@/lib/auth'; 4 | 5 | // 定义初始化请求数据类型 6 | interface InitRequestData { 7 | username?: string; 8 | password?: string; 9 | [key: string]: string | undefined; 10 | } 11 | 12 | // 简单数据验证函数 13 | function validateInitData(data: InitRequestData) { 14 | const errors: string[] = []; 15 | 16 | if (!data.username) { 17 | errors.push('管理员账户名不能为空'); 18 | } 19 | 20 | if (!data.password) { 21 | errors.push('密码不能为空'); 22 | } else if (data.password.length < 6) { 23 | errors.push('密码长度至少为6个字符'); 24 | } 25 | 26 | return { isValid: errors.length === 0, errors }; 27 | } 28 | 29 | // 系统初始化API 30 | export async function POST(req: Request) { 31 | try { 32 | // 解析请求体 33 | const body: InitRequestData = await req.json(); 34 | 35 | // 验证请求数据 36 | const { isValid, errors } = validateInitData(body); 37 | if (!isValid) { 38 | return NextResponse.json({ 39 | success: false, 40 | message: '请求数据验证失败', 41 | errors 42 | }, { status: 400 }); 43 | } 44 | 45 | // 由于验证已通过,可以安全地断言不为undefined 46 | const username = body.username as string; 47 | const password = body.password as string; 48 | 49 | // 检查是否已存在管理员 50 | const adminExists = await hasAdminUser(); 51 | if (adminExists) { 52 | return NextResponse.json({ 53 | success: false, 54 | message: '系统已初始化,管理员账户已存在' 55 | }, { status: 409 }); 56 | } 57 | 58 | // 创建JWT密钥 59 | await getOrCreateJwtSecret(); 60 | 61 | // 创建管理员账户 62 | await createUser(null, username, password, true); 63 | 64 | return NextResponse.json({ 65 | success: true, 66 | message: '系统初始化成功,管理员账户和JWT密钥已创建' 67 | }); 68 | } catch (error) { 69 | console.error('系统初始化错误:', error); 70 | 71 | return NextResponse.json({ 72 | success: false, 73 | message: '系统初始化失败' 74 | }, { status: 500 }); 75 | } 76 | } 77 | 78 | // 获取系统初始化状态 79 | export async function GET() { 80 | try { 81 | // 检查是否有管理员和JWT密钥 82 | const adminExists = await hasAdminUser(); 83 | 84 | return NextResponse.json({ 85 | success: true, 86 | initialized: adminExists, 87 | message: adminExists ? '系统已初始化' : '系统尚未初始化' 88 | }); 89 | } catch (error) { 90 | console.error('获取系统状态错误:', error); 91 | 92 | return NextResponse.json({ 93 | success: false, 94 | message: '获取系统状态失败' 95 | }, { status: 500 }); 96 | } 97 | } -------------------------------------------------------------------------------- /prisma/migrations/20250422030024_add_system_config/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "SystemConfig" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "key" TEXT NOT NULL, 5 | "value" TEXT NOT NULL, 6 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" DATETIME NOT NULL 8 | ); 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key"); 12 | 13 | -- 插入默认系统配置 14 | INSERT INTO "SystemConfig" ("id", "key", "value", "createdAt", "updatedAt") 15 | VALUES 16 | (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), 'timezone', 'Asia/Shanghai', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), 17 | (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), 'data_retention_days', '90', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), 18 | (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), 'proxy_enabled', 'false', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), 19 | (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), 'proxy_server', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), 20 | (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), 'proxy_port', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), 21 | (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), 'proxy_username', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), 22 | (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), 'proxy_password', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); 23 | -------------------------------------------------------------------------------- /src/components/confirm-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import { createPortal } from "react-dom"; 5 | 6 | interface ConfirmDialogProps { 7 | isOpen: boolean; 8 | title: string; 9 | message: string; 10 | confirmText?: string; 11 | cancelText?: string; 12 | onConfirm: () => void; 13 | onCancel: () => void; 14 | isDestructive?: boolean; 15 | } 16 | 17 | export function ConfirmDialog({ 18 | isOpen, 19 | title, 20 | message, 21 | confirmText = "确认", 22 | cancelText = "取消", 23 | onConfirm, 24 | onCancel, 25 | isDestructive = false 26 | }: ConfirmDialogProps) { 27 | const [isMounted, setIsMounted] = useState(false); 28 | 29 | useEffect(() => { 30 | setIsMounted(true); 31 | 32 | if (isOpen) { 33 | // 打开对话框时禁止背景滚动 34 | document.body.style.overflow = "hidden"; 35 | } 36 | 37 | return () => { 38 | // 关闭对话框时恢复背景滚动 39 | document.body.style.overflow = "auto"; 40 | }; 41 | }, [isOpen]); 42 | 43 | // 客户端环境检查 44 | if (!isMounted || !isOpen) return null; 45 | 46 | const dialog = ( 47 |
48 | {/* 背景遮罩 */} 49 |
53 | 54 | {/* 对话框内容 */} 55 |
56 |
57 |

{title}

58 |

{message}

59 | 60 |
61 | 67 | 77 |
78 |
79 |
80 |
81 | ); 82 | 83 | // 使用 portal 将对话框渲染到 body 中 84 | return createPortal(dialog, document.body); 85 | } -------------------------------------------------------------------------------- /src/types/monitor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 监控项通知绑定关系 3 | */ 4 | export interface MonitorNotificationBinding { 5 | id: string; 6 | monitorId: string; 7 | notificationChannelId: string; 8 | enabled: boolean; 9 | createdAt: Date; 10 | updatedAt: Date; 11 | notificationChannel: { 12 | id: string; 13 | name: string; 14 | type: string; 15 | enabled: boolean; 16 | config: Record; 17 | createdAt: Date; 18 | updatedAt: Date; 19 | }; 20 | } 21 | 22 | /** 23 | * 监控状态记录 24 | */ 25 | export interface MonitorStatus { 26 | id: string; 27 | monitorId: string; 28 | status: number; 29 | message?: string; 30 | ping?: number; 31 | timestamp: Date; 32 | } 33 | 34 | /** 35 | * 扩展的监控项数据类型 36 | */ 37 | export interface ExtendedMonitor { 38 | id: string; 39 | name: string; 40 | type: string; 41 | config: Record; 42 | active: boolean; 43 | interval: number; 44 | retries: number; 45 | retryInterval: number; 46 | resendInterval: number; 47 | upsideDown: boolean; 48 | description?: string; 49 | createdAt: Date; 50 | updatedAt: Date; 51 | lastCheckAt?: Date | null; 52 | nextCheckAt?: Date | null; 53 | lastStatus?: number | null; 54 | notificationBindings?: MonitorNotificationBinding[]; 55 | statusHistory?: MonitorStatus[]; 56 | } 57 | 58 | /** 59 | * 简化的通知绑定数据 60 | */ 61 | export interface SimpleNotificationBinding { 62 | notificationId: string; 63 | enabled: boolean; 64 | } 65 | 66 | /** 67 | * 监控表单数据 68 | */ 69 | export interface MonitorFormData { 70 | name: string; 71 | type: string; 72 | url?: string; 73 | httpMethod?: string; 74 | statusCodes?: string; 75 | maxRedirects?: number | string; 76 | ignoreTls?: boolean; 77 | requestBody?: string; 78 | requestHeaders?: string; 79 | keyword?: string; 80 | hostname?: string; 81 | port?: number | string; 82 | username?: string; 83 | password?: string; 84 | database?: string; 85 | query?: string; 86 | interval?: number | string; 87 | retries?: number | string; 88 | retryInterval?: number | string; 89 | resendInterval?: number | string; 90 | upsideDown?: boolean; 91 | description?: string; 92 | active?: boolean; 93 | notificationBindings?: SimpleNotificationBinding[]; 94 | config?: { 95 | url?: string; 96 | httpMethod?: string; 97 | statusCodes?: string; 98 | maxRedirects?: number; 99 | ignoreTls?: boolean; 100 | requestBody?: string; 101 | requestHeaders?: string; 102 | keyword?: string; 103 | hostname?: string; 104 | port?: number; 105 | username?: string; 106 | password?: string; 107 | database?: string; 108 | query?: string; 109 | }; 110 | } -------------------------------------------------------------------------------- /src/app/api/push/[token]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { monitorOperations } from '@/lib/db'; 3 | import { prisma } from '@/lib/prisma'; 4 | 5 | export async function GET( 6 | request: Request, 7 | { params }: { params: Promise<{ token: string }> } 8 | ) { 9 | try { 10 | // 获取token参数 11 | const paramsObj = await params; 12 | const token = paramsObj.token; 13 | 14 | // 解析URL参数 15 | const url = new URL(request.url); 16 | const status = url.searchParams.get('status')?.toLowerCase(); 17 | const msg = url.searchParams.get('msg') || 'Unknown'; 18 | const ping = parseInt(url.searchParams.get('ping') || '0'); 19 | 20 | if (!token) { 21 | console.error('Push监控请求缺失token'); 22 | return NextResponse.json( 23 | { error: '无效的请求,缺少token参数' }, 24 | { status: 400 } 25 | ); 26 | } 27 | 28 | // 查找对应的监控项 29 | const monitor = await monitorOperations.findMonitorByToken(token); 30 | 31 | // 如果没有找到对应的监控项,则返回错误 32 | if (!monitor) { 33 | console.error('未找到匹配的Push监控,token:', token); 34 | return NextResponse.json( 35 | { error: '无效的token或监控项不存在' }, 36 | { status: 404 } 37 | ); 38 | } 39 | 40 | // 确定状态值 41 | const numericStatus = status === 'up' ? 1 42 | : status === 'down' ? 0 43 | : Number(status) === 1 ? 1 44 | : Number(status) === 0 ? 0 45 | : 1; // 默认为up 46 | 47 | // 更新监控状态 48 | await monitorOperations.updateMonitorStatus({ 49 | monitorId: monitor.id, 50 | status: numericStatus, 51 | message: msg, 52 | ping: ping || null, 53 | }); 54 | 55 | // 更新最后推送时间 56 | // 使用事务确保数据一致性 57 | await prisma.$transaction(async (tx) => { 58 | // 获取现有配置 59 | const currentMonitor = await tx.monitor.findUnique({ 60 | where: { id: monitor.id } 61 | }); 62 | 63 | if (currentMonitor && currentMonitor.config) { 64 | // 更新config中的lastPushTime字段 65 | const config = { 66 | ...(currentMonitor.config as Record), 67 | lastPushTime: new Date().toISOString() 68 | }; 69 | 70 | // 保存更新后的配置 71 | await tx.monitor.update({ 72 | where: { id: monitor.id }, 73 | data: { config } 74 | }); 75 | } 76 | }); 77 | 78 | return NextResponse.json({ 79 | message: '状态更新成功', 80 | status: numericStatus === 1 ? 'up' : 'down' 81 | }); 82 | } catch (error) { 83 | console.error('处理Push监控请求失败:', error); 84 | return NextResponse.json( 85 | { error: '处理请求失败,请稍后重试' }, 86 | { status: 500 } 87 | ); 88 | } 89 | } -------------------------------------------------------------------------------- /src/app/api/monitor-groups/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { getServerSession } from 'next-auth'; 3 | import { authOptions } from '@/lib/auth'; 4 | import { prisma } from '@/lib/prisma'; 5 | 6 | // 获取所有分组 7 | export async function GET() { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | if (!session?.user?.id) { 11 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 12 | } 13 | 14 | const groups = await prisma.monitorGroup.findMany({ 15 | where: { 16 | createdById: session.user.id 17 | }, 18 | include: { 19 | monitors: { 20 | select: { 21 | id: true, 22 | name: true, 23 | lastStatus: true, 24 | active: true 25 | } 26 | } 27 | }, 28 | orderBy: [ 29 | { displayOrder: 'asc' }, 30 | { createdAt: 'asc' } 31 | ] 32 | }); 33 | 34 | return NextResponse.json(groups); 35 | } catch (error) { 36 | console.error('获取分组失败:', error); 37 | return NextResponse.json({ error: '获取分组失败' }, { status: 500 }); 38 | } 39 | } 40 | 41 | // 创建新分组 42 | export async function POST(request: NextRequest) { 43 | try { 44 | const session = await getServerSession(authOptions); 45 | if (!session?.user?.id) { 46 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 47 | } 48 | 49 | const body = await request.json(); 50 | const { name, description, color } = body; 51 | 52 | if (!name || name.trim() === '') { 53 | return NextResponse.json({ error: '分组名称不能为空' }, { status: 400 }); 54 | } 55 | 56 | // 检查分组名称是否已存在 57 | const existingGroup = await prisma.monitorGroup.findFirst({ 58 | where: { 59 | name: name.trim(), 60 | createdById: session.user.id 61 | } 62 | }); 63 | 64 | if (existingGroup) { 65 | return NextResponse.json({ error: '分组名称已存在' }, { status: 400 }); 66 | } 67 | 68 | // 获取当前最大排序值 69 | const maxOrder = await prisma.monitorGroup.findFirst({ 70 | where: { createdById: session.user.id }, 71 | orderBy: { displayOrder: 'desc' }, 72 | select: { displayOrder: true } 73 | }); 74 | 75 | const newGroup = await prisma.monitorGroup.create({ 76 | data: { 77 | name: name.trim(), 78 | description: description?.trim() || null, 79 | color: color || null, 80 | displayOrder: (maxOrder?.displayOrder || 0) + 1, 81 | createdById: session.user.id 82 | } 83 | }); 84 | 85 | return NextResponse.json(newGroup, { status: 201 }); 86 | } catch (error) { 87 | console.error('创建分组失败:', error); 88 | return NextResponse.json({ error: '创建分组失败' }, { status: 500 }); 89 | } 90 | } -------------------------------------------------------------------------------- /src/lib/utils/compact-id.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 紧凑ID生成工具 3 | * 生成比UUID更短但仍然唯一的ID 4 | */ 5 | 6 | // Base36字符集:0-9, a-z 7 | const BASE36_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'; 8 | 9 | /** 10 | * 生成紧凑的时间戳+随机数ID 11 | * 格式:时间戳(base36) + 随机数(base36) 12 | * 长度:约12-15字符,比UUID的36字符短很多 13 | */ 14 | export function generateCompactId(): string { 15 | // 时间戳转base36 (约6-7字符) 16 | const timestamp = Date.now().toString(36); 17 | 18 | // 4位随机数转base36 (约3字符) 19 | const random1 = Math.floor(Math.random() * 1296).toString(36); // 36^2 = 1296 20 | 21 | // 额外4位随机数保证唯一性 (约3字符) 22 | const random2 = Math.floor(Math.random() * 1296).toString(36); 23 | 24 | return timestamp + random1 + random2; 25 | } 26 | 27 | /** 28 | * 生成超短ID(适用于高频记录) 29 | * 格式:时间戳(base36后6位) + 2位随机数 30 | * 长度:8字符 31 | * 注意:适合短期使用,长期可能有冲突风险 32 | */ 33 | export function generateUltraCompactId(): string { 34 | // 取时间戳的后6位base36字符 35 | const timestamp = Date.now().toString(36).slice(-6); 36 | 37 | // 2位随机数 38 | const random = Math.floor(Math.random() * 1296).toString(36).padStart(2, '0'); 39 | 40 | return timestamp + random; 41 | } 42 | 43 | /** 44 | * 验证ID格式类型 45 | */ 46 | export function getIdType(id: string): 'uuid' | 'cuid' | 'compact' | 'ultra-compact' | 'unknown' { 47 | // UUID格式:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 48 | if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { 49 | return 'uuid'; 50 | } 51 | 52 | // CUID格式:cxxxxxxxxxxxxxxxxxxxxxxxxx 53 | if (/^c[0-9a-z]{24}$/.test(id)) { 54 | return 'cuid'; 55 | } 56 | 57 | // 超紧凑ID格式:6-8位字母数字 58 | if (/^[0-9a-z]{6,8}$/.test(id)) { 59 | return 'ultra-compact'; 60 | } 61 | 62 | // 紧凑ID格式:9-15位字母数字 63 | if (/^[0-9a-z]{9,15}$/.test(id)) { 64 | return 'compact'; 65 | } 66 | 67 | return 'unknown'; 68 | } 69 | 70 | /** 71 | * 验证ID格式是否为紧凑ID 72 | */ 73 | export function isCompactId(id: string): boolean { 74 | return getIdType(id) === 'compact'; 75 | } 76 | 77 | /** 78 | * 检查是否为旧格式ID(UUID或CUID) 79 | */ 80 | export function isLegacyId(id: string): boolean { 81 | const type = getIdType(id); 82 | return type === 'uuid' || type === 'cuid'; 83 | } 84 | 85 | /** 86 | * 检查是否为超紧凑ID 87 | */ 88 | export function isUltraCompactId(id: string): boolean { 89 | return getIdType(id) === 'ultra-compact'; 90 | } 91 | 92 | /** 93 | * 从紧凑ID中提取时间戳(如果可能) 94 | */ 95 | export function extractTimestampFromCompactId(id: string): number | null { 96 | if (!isCompactId(id) || id.length < 8) { 97 | return null; 98 | } 99 | 100 | try { 101 | // 尝试解析前面的时间戳部分 102 | const timestampPart = id.slice(0, -6); // 去掉后6位随机数部分 103 | return parseInt(timestampPart, 36); 104 | } catch { 105 | return null; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createContext, useContext, useEffect, useState } from "react"; 4 | 5 | type Theme = "dark" | "light" | "system"; 6 | 7 | type ThemeProviderProps = { 8 | children: React.ReactNode; 9 | defaultTheme?: Theme; 10 | }; 11 | 12 | type ThemeProviderState = { 13 | theme: Theme; 14 | setTheme: (theme: Theme) => void; 15 | }; 16 | 17 | const initialState: ThemeProviderState = { 18 | theme: "system", 19 | setTheme: () => null, 20 | }; 21 | 22 | // localStorage key 23 | const THEME_STORAGE_KEY = "coolmonitor-theme"; 24 | 25 | const ThemeProviderContext = createContext(initialState); 26 | 27 | export function ThemeProvider({ 28 | children, 29 | defaultTheme = "system", 30 | ...props 31 | }: ThemeProviderProps) { 32 | // 尝试从localStorage读取保存的主题,如果没有则使用默认主题 33 | const [theme, setTheme] = useState(() => { 34 | // 只在客户端执行 35 | if (typeof window !== "undefined") { 36 | const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as Theme | null; 37 | return savedTheme || defaultTheme; 38 | } 39 | return defaultTheme; 40 | }); 41 | 42 | // 当主题改变时,更新DOM和localStorage 43 | useEffect(() => { 44 | const root = window.document.documentElement; 45 | 46 | // 先移除所有主题类 47 | root.classList.remove("dark", "light"); 48 | 49 | // 再添加当前主题类 50 | if (theme === "system") { 51 | const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; 52 | root.classList.add(isDark ? "dark" : "light"); 53 | } else { 54 | root.classList.add(theme); 55 | } 56 | 57 | // 保存到localStorage 58 | localStorage.setItem(THEME_STORAGE_KEY, theme); 59 | }, [theme]); 60 | 61 | // 监听系统主题变化 62 | useEffect(() => { 63 | if (theme === "system") { 64 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 65 | 66 | const handleChange = () => { 67 | const root = window.document.documentElement; 68 | // 移除所有主题类 69 | root.classList.remove("dark", "light"); 70 | // 添加适当的类 71 | root.classList.add(mediaQuery.matches ? "dark" : "light"); 72 | }; 73 | 74 | mediaQuery.addEventListener("change", handleChange); 75 | return () => mediaQuery.removeEventListener("change", handleChange); 76 | } 77 | }, [theme]); 78 | 79 | const value = { 80 | theme, 81 | setTheme: (theme: Theme) => setTheme(theme), 82 | }; 83 | 84 | return ( 85 | 86 | {children} 87 | 88 | ); 89 | } 90 | 91 | export const useTheme = () => { 92 | const context = useContext(ThemeProviderContext); 93 | 94 | if (context === undefined) 95 | throw new Error("useTheme must be used within a ThemeProvider"); 96 | 97 | return context; 98 | }; -------------------------------------------------------------------------------- /src/lib/utils/compact-message.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 紧凑消息生成工具 3 | * 大幅减少正常状态下的message字段存储空间 4 | */ 5 | 6 | export interface CompactMessageOptions { 7 | /** 预留选项,当前策略是正常状态始终为null */ 8 | reserved?: boolean; 9 | } 10 | 11 | /** 12 | * 为监控结果生成紧凑消息 13 | * 14 | * 极简策略: 15 | * 1. 正常状态(UP):完全不存储message(null),status字段已足够表示成功 16 | * 2. 错误状态(DOWN):保留完整错误信息用于调试 17 | * 3. ping字段已存储响应时间,message中不重复存储时间信息 18 | */ 19 | export function generateCompactMessage( 20 | status: number, 21 | originalMessage: string, 22 | ping?: number 23 | ): string | null { 24 | // 状态定义 25 | const MONITOR_STATUS = { 26 | DOWN: 0, 27 | UP: 1, 28 | PENDING: 2 29 | }; 30 | 31 | // 正常状态:完全不存储message,status=1已经表示成功 32 | if (status === MONITOR_STATUS.UP) { 33 | return null; 34 | } 35 | 36 | // 错误状态:保留完整错误信息,这很重要用于调试 37 | if (status === MONITOR_STATUS.DOWN) { 38 | return originalMessage; 39 | } 40 | 41 | // 等待状态:简短表示 42 | if (status === MONITOR_STATUS.PENDING) { 43 | return '等待中'; 44 | } 45 | 46 | // 其他情况保持原样 47 | return originalMessage; 48 | } 49 | 50 | /** 51 | * 生成UI显示消息(兼容新旧数据格式) 52 | * 53 | * 兼容性处理: 54 | * - 新数据:正常状态message为null,根据状态和类型生成显示文本 55 | * - 旧数据:正常状态有完整message,直接显示(保持向后兼容) 56 | * - 错误状态:始终显示详细信息 57 | */ 58 | export function generateDisplayMessage( 59 | status: number, 60 | storedMessage: string | null, 61 | ping?: number, 62 | monitorType?: string 63 | ): string { 64 | const MONITOR_STATUS = { 65 | DOWN: 0, 66 | UP: 1, 67 | PENDING: 2 68 | }; 69 | 70 | // 如果有存储的message,优先使用(向后兼容旧数据) 71 | if (storedMessage) { 72 | return storedMessage; 73 | } 74 | 75 | // 新数据格式:正常状态message为null,需要生成显示文本 76 | if (status === MONITOR_STATUS.UP) { 77 | switch (monitorType) { 78 | case 'icmp': 79 | case 'http': 80 | return ping ? `响应正常 ${ping}ms` : '响应正常'; 81 | case 'keyword': 82 | return '关键词检测通过'; 83 | case 'port': 84 | return '端口连接正常'; 85 | case 'mysql': 86 | case 'redis': 87 | return '数据库连接正常'; 88 | case 'https-cert': 89 | return '证书有效'; 90 | case 'push': 91 | return '推送正常'; 92 | default: 93 | return '监控正常'; 94 | } 95 | } 96 | 97 | // 错误或等待状态但没有消息 98 | if (status === MONITOR_STATUS.DOWN) { 99 | return '监控异常'; 100 | } 101 | 102 | if (status === MONITOR_STATUS.PENDING) { 103 | return '等待中'; 104 | } 105 | 106 | return '状态未知'; 107 | } 108 | 109 | /** 110 | * 计算消息压缩比率 111 | */ 112 | export function calculateCompressionRatio( 113 | originalMessage: string, 114 | compactMessage: string | null 115 | ): number { 116 | const originalSize = originalMessage.length; 117 | const compactSize = compactMessage?.length || 0; 118 | 119 | if (originalSize === 0) return 0; 120 | 121 | return ((originalSize - compactSize) / originalSize) * 100; 122 | } 123 | -------------------------------------------------------------------------------- /src/lib/system-config.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from './prisma'; 2 | import crypto from 'crypto'; 3 | 4 | // 系统配置键名常量 5 | export const CONFIG_KEYS = { 6 | REGISTRATION_ENABLED: 'registration_enabled', 7 | }; 8 | 9 | // 获取系统配置值 10 | export async function getSystemConfig(key: string): Promise { 11 | try { 12 | const config = await prisma.systemConfig.findUnique({ 13 | where: { key }, 14 | }); 15 | 16 | return config?.value || null; 17 | } catch (error) { 18 | console.error(`获取系统配置${key}失败:`, error); 19 | return null; 20 | } 21 | } 22 | 23 | // 设置系统配置值 24 | export async function setSystemConfig(key: string, value: string): Promise { 25 | try { 26 | await prisma.systemConfig.upsert({ 27 | where: { key }, 28 | update: { value, updatedAt: new Date() }, 29 | create: { key, value }, 30 | }); 31 | console.log(`系统配置${key}更新成功`); 32 | } catch (error) { 33 | console.error(`设置系统配置${key}失败:`, error); 34 | throw new Error(`无法保存系统配置: ${error instanceof Error ? error.message : String(error)}`); 35 | } 36 | } 37 | 38 | // JWT密钥长度常量 39 | const JWT_SECRET_LENGTH = 32; // 固定长度,确保一致性 40 | 41 | // 生成随机JWT密钥 42 | export function generateRandomSecret(): string { 43 | try { 44 | return crypto.randomBytes(JWT_SECRET_LENGTH).toString('hex'); 45 | } catch (error) { 46 | console.error('生成随机密钥失败:', error); 47 | // 备用方案:使用Math.random 48 | const backupRandom = Array.from( 49 | { length: JWT_SECRET_LENGTH * 2 }, 50 | () => Math.floor(Math.random() * 16).toString(16) 51 | ).join(''); 52 | console.log('使用备用方法生成密钥'); 53 | return backupRandom; 54 | } 55 | } 56 | 57 | // 获取JWT密钥 58 | export async function getOrCreateJwtSecret(): Promise { 59 | // 使用环境变量中的NEXTAUTH_SECRET 60 | const envSecret = process.env.NEXTAUTH_SECRET; 61 | if (envSecret) { 62 | return envSecret; 63 | } 64 | 65 | // 如果环境变量不存在,生成一个临时密钥并发出警告 66 | console.warn('未找到NEXTAUTH_SECRET环境变量,使用临时JWT密钥,重启后会话将失效!'); 67 | return generateRandomSecret(); 68 | } 69 | 70 | // 检查注册功能是否启用 71 | export async function isRegistrationEnabled(): Promise { 72 | const value = await getSystemConfig(CONFIG_KEYS.REGISTRATION_ENABLED); 73 | // 默认值为 'true',仅当明确设置为 'false' 时禁用注册 74 | return value !== 'false'; 75 | } 76 | 77 | // 设置注册功能状态 78 | export async function setRegistrationEnabled(enabled: boolean): Promise { 79 | await setSystemConfig(CONFIG_KEYS.REGISTRATION_ENABLED, enabled ? 'true' : 'false'); 80 | } 81 | 82 | // 系统初始化后禁用注册功能 83 | export async function disableRegistrationAfterInit(): Promise { 84 | const hasAdmin = await prisma.user.count({ 85 | where: { isAdmin: true } 86 | }); 87 | 88 | // 检查是否已经有管理员用户,如果有,则禁用注册 89 | if (hasAdmin > 0) { 90 | console.log('系统已初始化(存在管理员用户),禁用注册功能'); 91 | await setRegistrationEnabled(false); 92 | } else { 93 | console.log('系统未初始化(不存在管理员用户),保持注册功能开启'); 94 | await setRegistrationEnabled(true); 95 | } 96 | } -------------------------------------------------------------------------------- /Dockerfile-ARM: -------------------------------------------------------------------------------- 1 | # 构建阶段 2 | FROM --platform=linux/arm64 node:20-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | # 使用国内npm镜像源 7 | RUN npm config set registry https://registry.npmmirror.com 8 | 9 | # 复制依赖文件 10 | COPY package.json package-lock.json* ./ 11 | 12 | # 安装依赖 13 | RUN npm ci 14 | 15 | # 复制源代码 16 | COPY . . 17 | 18 | # 生成Prisma客户端 19 | RUN npx prisma generate 20 | 21 | # 预先生成SQLite数据库,并确保创建成功 22 | RUN mkdir -p /app/prisma/template && \ 23 | echo "正在生成数据库模板..." && \ 24 | npx prisma migrate deploy && \ 25 | DB_FILE=$(find /app -name "*.db" -type f | head -n 1) && \ 26 | if [ -n "$DB_FILE" ]; then \ 27 | echo "找到数据库文件: $DB_FILE" && \ 28 | cp "$DB_FILE" /app/prisma/template/coolmonitor.db && \ 29 | echo "数据库模板已保存到: /app/prisma/template/coolmonitor.db"; \ 30 | else \ 31 | echo "警告: 未找到数据库文件!" && \ 32 | touch /app/prisma/template/coolmonitor.db && \ 33 | echo "已创建空的数据库模板文件"; \ 34 | fi 35 | 36 | # 构建应用 37 | RUN npm run build 38 | 39 | # 中间阶段 - 提取相关文件并删除不必要的依赖 40 | FROM --platform=linux/arm64 node:20-alpine AS extractor 41 | 42 | WORKDIR /app 43 | 44 | COPY --from=builder /app/.next/standalone ./ 45 | COPY --from=builder /app/.next/static ./.next/static 46 | COPY --from=builder /app/public ./public 47 | COPY --from=builder /app/prisma/template ./prisma/template 48 | COPY --from=builder /app/startup.sh ./startup.sh 49 | 50 | # 清理不必要的文件和目录 51 | RUN find . -name "*.map" -type f -delete && \ 52 | find . -path "*/node_modules/.bin/*" -delete && \ 53 | find ./node_modules -name "README*" -delete && \ 54 | find ./node_modules -name "readme*" -delete && \ 55 | find ./node_modules -name "CHANGELOG*" -delete && \ 56 | find ./node_modules -name "LICENSE*" -delete && \ 57 | find ./node_modules -name "*.d.ts" -delete && \ 58 | find ./node_modules -path "*/test/*" -delete && \ 59 | find ./node_modules -path "*/tests/*" -delete && \ 60 | find ./node_modules -path "*/.github/*" -delete 61 | 62 | # 生产阶段 63 | FROM --platform=linux/arm64 alpine:3.19 AS runner 64 | 65 | # 安装Node.js运行时,不安装npm等开发工具 66 | RUN apk add --no-cache nodejs 67 | 68 | WORKDIR /app 69 | 70 | # 安装dos2unix进行行尾处理 71 | RUN apk add --no-cache dos2unix 72 | 73 | # 复制应用文件 74 | COPY --from=extractor /app ./ 75 | 76 | # 处理启动脚本 77 | RUN dos2unix ./startup.sh && \ 78 | chmod +x ./startup.sh && \ 79 | apk del --purge dos2unix 80 | 81 | # 创建数据目录并设置权限 82 | RUN mkdir -p /app/data 83 | 84 | # 使用root用户运行,不再创建非root用户 85 | # 注释掉创建用户的命令 86 | # RUN addgroup -S nodejs && \ 87 | # adduser -S nextjs -G nodejs && \ 88 | # mkdir -p /app/data && \ 89 | # chown -R nextjs:nodejs /app 90 | 91 | # 注释掉切换用户的命令 92 | # USER nextjs 93 | 94 | EXPOSE 3333 95 | 96 | ENV PORT=3333 97 | ENV HOSTNAME="0.0.0.0" 98 | ENV NODE_ENV=production 99 | # ARM特定优化 100 | ENV NODE_OPTIONS="--max-old-space-size=2048" 101 | 102 | # 数据卷 - 用于SQLite数据库文件 103 | VOLUME ["/app/data"] 104 | 105 | # 启动应用 106 | CMD ["./startup.sh"] -------------------------------------------------------------------------------- /src/app/api/monitors/[id]/history/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { prisma } from '@/lib/prisma'; 3 | import { validateAuth } from '@/lib/auth-helpers'; 4 | import { getServerSession } from 'next-auth'; 5 | import { buildAuthOptions } from '@/app/api/auth/[...nextauth]/route'; 6 | 7 | export async function GET( 8 | request: NextRequest, 9 | { params }: { params: Promise<{ id: string }> } 10 | ) { 11 | try { 12 | // 验证用户是否已登录 13 | const authError = await validateAuth(); 14 | if (authError) return authError; 15 | 16 | // 获取用户信息 17 | const authOptions = await buildAuthOptions(); 18 | const session = await getServerSession(authOptions); 19 | if (!session?.user) { 20 | return NextResponse.json( 21 | { error: '未授权的请求' }, 22 | { status: 401 } 23 | ); 24 | } 25 | 26 | // 获取监控ID参数 27 | const { id } = await params; 28 | const monitorId = id; 29 | 30 | // 验证用户是否有权限访问该监控项 31 | const monitor = await prisma.monitor.findUnique({ 32 | where: { 33 | id: monitorId, 34 | // 如果是管理员,可以访问所有监控项 35 | ...(session.user.isAdmin ? {} : { createdById: session.user.id }) 36 | } 37 | }); 38 | 39 | if (!monitor) { 40 | return NextResponse.json( 41 | { error: '无权访问此监控项' }, 42 | { status: 403 } 43 | ); 44 | } 45 | 46 | const { searchParams } = new URL(request.url); 47 | const range = searchParams.get('range') || '2h'; 48 | 49 | // 计算时间范围 50 | const now = new Date(); 51 | let timeAgo = new Date(); 52 | 53 | switch(range) { 54 | case '2h': 55 | timeAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); 56 | break; 57 | case '24h': 58 | timeAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); 59 | break; 60 | case '7d': 61 | timeAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); 62 | break; 63 | case '30d': 64 | timeAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); 65 | break; 66 | case '90d': 67 | timeAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); 68 | break; 69 | default: 70 | timeAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); 71 | } 72 | 73 | // 获取监控历史记录 74 | const history = await prisma.monitorStatus.findMany({ 75 | where: { 76 | monitorId: monitorId, 77 | timestamp: { 78 | gte: timeAgo 79 | } 80 | }, 81 | orderBy: { 82 | timestamp: 'asc' 83 | }, 84 | select: { 85 | id: true, 86 | status: true, 87 | message: true, 88 | ping: true, 89 | timestamp: true 90 | } 91 | }); 92 | 93 | return NextResponse.json(history); 94 | } catch (error) { 95 | console.error('获取监控历史记录失败:', error); 96 | return NextResponse.json( 97 | { error: '获取监控历史记录失败' }, 98 | { status: 500 } 99 | ); 100 | } 101 | } -------------------------------------------------------------------------------- /src/lib/monitors/checker-icmp.ts: -------------------------------------------------------------------------------- 1 | import { MonitorIcmpConfig, MonitorCheckResult, MONITOR_STATUS } from './types'; 2 | import ping from 'ping'; 3 | 4 | /** 5 | * 执行ICMP Ping检查(单次执行,不包含重试逻辑) 6 | * @param config ICMP监控配置 7 | * @returns 检查结果 8 | */ 9 | async function checkIcmpSingle(config: MonitorIcmpConfig): Promise { 10 | try { 11 | const { hostname, packetCount = 4, maxResponseTime } = config; 12 | 13 | // 使用ping库发送ICMP请求 14 | const pingOptions = { 15 | timeout: 10, // 10秒超时 16 | // 根据操作系统设置不同的参数 17 | extra: process.platform === 'win32' ? 18 | ['-n', packetCount.toString()] : 19 | ['-c', packetCount.toString()] 20 | }; 21 | 22 | const pingResult = await ping.promise.probe(hostname, pingOptions); 23 | 24 | // 解析ping结果 25 | const isAlive = pingResult.alive; 26 | const pingTime = pingResult.time === 'unknown' ? null : parseFloat(pingResult.time); 27 | 28 | // 判断状态 29 | if (!isAlive) { 30 | return { 31 | status: MONITOR_STATUS.DOWN, 32 | message: '目标不可达', 33 | ping: null 34 | }; 35 | } else if (maxResponseTime && pingTime && pingTime > maxResponseTime) { 36 | return { 37 | status: MONITOR_STATUS.DOWN, 38 | message: `响应时间(${pingTime}ms)超过阈值(${maxResponseTime}ms)`, 39 | ping: pingTime 40 | }; 41 | } 42 | 43 | return { 44 | status: MONITOR_STATUS.UP, 45 | message: pingTime ? `Ping正常: ${pingTime}ms` : 'Ping正常', 46 | ping: pingTime 47 | }; 48 | } catch (error) { 49 | return { 50 | status: MONITOR_STATUS.DOWN, 51 | message: `Ping执行错误: ${error instanceof Error ? error.message : String(error)}`, 52 | ping: null 53 | }; 54 | } 55 | } 56 | 57 | /** 58 | * 执行ICMP Ping检查(包含重试逻辑) 59 | * @param config ICMP监控配置 60 | * @returns 检查结果 61 | */ 62 | export async function checkIcmp(config: MonitorIcmpConfig): Promise { 63 | const { retries = 0, retryInterval = 60 } = config; 64 | 65 | // 如果没有设置重试次数,直接执行单次检查 66 | if (retries === 0) { 67 | return await checkIcmpSingle(config); 68 | } 69 | 70 | // 执行首次检查 71 | const result = await checkIcmpSingle(config); 72 | 73 | // 如果首次检查成功,直接返回 74 | if (result.status === MONITOR_STATUS.UP) { 75 | return result; 76 | } 77 | 78 | // 如果配置了重试次数且首次检查失败,进行重试 79 | if (retries > 0) { 80 | for (let i = 0; i < retries; i++) { 81 | // 等待重试间隔时间(秒) 82 | await new Promise(resolve => setTimeout(resolve, retryInterval * 1000)); 83 | 84 | // 执行重试检查 85 | const retryResult = await checkIcmpSingle(config); 86 | 87 | if (retryResult.status === MONITOR_STATUS.UP) { 88 | return { 89 | ...retryResult, 90 | message: `重试成功 (${i + 1}/${retries}): ${retryResult.message}` 91 | }; 92 | } 93 | } 94 | 95 | return { 96 | ...result, 97 | message: `重试${retries}次后仍然失败: ${result.message}` 98 | }; 99 | } 100 | 101 | return result; 102 | } -------------------------------------------------------------------------------- /src/lib/monitors/checker.ts: -------------------------------------------------------------------------------- 1 | import { Monitor } from '@prisma/client'; 2 | import { checkers } from './index'; 3 | import { MonitorCheckResult } from './types'; 4 | 5 | export interface CheckResult extends Omit { 6 | ping?: number | null; 7 | details?: Record; 8 | } 9 | 10 | // 执行监控检查 11 | export async function executeMonitorCheck(monitor: Monitor): Promise { 12 | try { 13 | let result: CheckResult; 14 | const config = typeof monitor.config === 'string' 15 | ? JSON.parse(monitor.config) 16 | : monitor.config; 17 | 18 | switch (monitor.type) { 19 | case 'http': 20 | result = await checkers.http({ url: config.url, ...config }); 21 | break; 22 | case 'https-cert': 23 | result = await checkers["https-cert"]({ url: config.url, ...config }); 24 | break; 25 | case 'keyword': 26 | result = await checkers.keyword({ url: config.url, keyword: config.keyword, ...config }); 27 | break; 28 | case 'port': 29 | result = await checkers.port({ hostname: config.hostname, port: config.port }); 30 | break; 31 | case 'mysql': 32 | case 'redis': 33 | result = await checkers.database(monitor.type, { 34 | hostname: config.hostname, 35 | port: config.port, 36 | username: config.username, 37 | password: config.password, 38 | database: config.database, 39 | query: config.query 40 | }); 41 | break; 42 | case 'push': 43 | result = await checkers.push({ 44 | token: config.token, 45 | pushInterval: config.pushInterval || monitor.interval 46 | }); 47 | break; 48 | case 'icmp': 49 | result = await checkers.icmp({ 50 | hostname: config.hostname, 51 | packetCount: config.packetCount, 52 | maxPacketLoss: config.maxPacketLoss, 53 | maxResponseTime: config.maxResponseTime 54 | }); 55 | break; 56 | default: 57 | return { 58 | status: 0, 59 | message: `不支持的监控类型: ${monitor.type}` 60 | }; 61 | } 62 | 63 | // 如果配置了重试次数且检查失败,进行重试 64 | if (result.status === 0 && monitor.retries > 0) { 65 | for (let i = 0; i < monitor.retries; i++) { 66 | // 等待重试间隔时间 67 | await new Promise(resolve => setTimeout(resolve, monitor.retryInterval * 1000)); 68 | 69 | const retryResult = await executeMonitorCheck({ 70 | ...monitor, 71 | retries: 0 // 防止重试时再次重试 72 | }); 73 | 74 | if (retryResult.status === 1) { 75 | return { 76 | ...retryResult, 77 | message: `重试成功 (${i + 1}/${monitor.retries}): ${retryResult.message}` 78 | }; 79 | } 80 | } 81 | 82 | return { 83 | ...result, 84 | message: `重试${monitor.retries}次后仍然失败: ${result.message}` 85 | }; 86 | } 87 | 88 | return result; 89 | } catch (error) { 90 | return { 91 | status: 0, 92 | message: `检查执行出错: ${error instanceof Error ? error.message : String(error)}` 93 | }; 94 | } 95 | } -------------------------------------------------------------------------------- /src/app/api/settings/proxy-test/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { proxyFetch } from '@/lib/monitors/proxy-fetch'; 3 | import { getAllProxySettings, updateSettings, SETTINGS_KEYS } from '@/lib/settings'; 4 | import { validateAuth } from '@/lib/auth-helpers'; 5 | 6 | // 测试代理连接 7 | export async function POST(request: NextRequest) { 8 | try { 9 | // 验证用户是否已登录 10 | const authError = await validateAuth(); 11 | if (authError) return authError; 12 | 13 | // 获取请求信息 14 | const data = await request.json(); 15 | 16 | // 检查代理设置是否有效 17 | const proxySettings = await getAllProxySettings(); 18 | const proxyEnabled = proxySettings[SETTINGS_KEYS.PROXY_ENABLED] === 'true'; 19 | const proxyServer = proxySettings[SETTINGS_KEYS.PROXY_SERVER]; 20 | const proxyPort = proxySettings[SETTINGS_KEYS.PROXY_PORT]; 21 | 22 | if (!proxyEnabled) { 23 | return NextResponse.json({ 24 | success: false, 25 | error: '代理功能未启用,请先启用代理' 26 | }); 27 | } 28 | 29 | if (!proxyServer || !proxyPort) { 30 | return NextResponse.json({ 31 | success: false, 32 | error: '代理服务器或端口未配置,请检查设置' 33 | }); 34 | } 35 | 36 | // 确保请求必须使用代理 - 如果临时设置了禁用代理,需要立即报错 37 | if (data.forceUpdateSettings === true) { 38 | await updateSettings({ 39 | [SETTINGS_KEYS.PROXY_ENABLED]: 'true' 40 | }); 41 | } 42 | 43 | // 测试URL,默认使用一个常见可访问的网站 44 | const testUrl = data.url || 'https://httpbin.org/ip'; 45 | 46 | // 通过代理发送请求,这里会强制使用代理 47 | const startTime = Date.now(); 48 | try { 49 | const response = await proxyFetch(testUrl); 50 | const responseTime = Date.now() - startTime; 51 | 52 | if (!response.ok) { 53 | return NextResponse.json({ 54 | success: false, 55 | error: `代理请求失败,状态码: ${response.status}`, 56 | statusCode: response.status 57 | }); 58 | } 59 | 60 | // 尝试读取响应内容 61 | const contentType = response.headers.get('content-type') || ''; 62 | const isJson = contentType.includes('application/json'); 63 | let responseBody; 64 | 65 | try { 66 | responseBody = isJson ? await response.json() : await response.text(); 67 | } catch (error) { 68 | responseBody = '无法解析响应内容'; 69 | } 70 | 71 | return NextResponse.json({ 72 | success: true, 73 | data: { 74 | ping: responseTime, 75 | statusCode: response.status, 76 | responseBody: responseBody, 77 | proxyServer: proxyServer, 78 | proxyPort: proxyPort 79 | } 80 | }); 81 | } catch (error) { 82 | // 这里是代理连接失败的情况 83 | return NextResponse.json({ 84 | success: false, 85 | error: `代理连接失败: ${error instanceof Error ? error.message : '未知错误'}`, 86 | proxyServer: proxyServer, 87 | proxyPort: proxyPort 88 | }); 89 | } 90 | } catch (error) { // eslint-disable-line @typescript-eslint/no-unused-vars 91 | console.error('代理测试失败:', error); 92 | return NextResponse.json( 93 | { 94 | success: false, 95 | error: error instanceof Error ? error.message : '代理测试失败' 96 | }, 97 | { status: 500 } 98 | ); 99 | } 100 | } -------------------------------------------------------------------------------- /src/lib/monitors.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from './prisma'; 2 | import { Prisma } from '@prisma/client'; 3 | 4 | /** 5 | * 监控配置接口 6 | */ 7 | export interface MonitorConfig { 8 | url?: string; 9 | hostname?: string; 10 | port?: number; 11 | httpMethod?: string; 12 | statusCodes?: string; 13 | maxRedirects?: number; 14 | requestBody?: string; 15 | requestHeaders?: string; 16 | keyword?: string; 17 | ignoreTls?: boolean; 18 | username?: string; 19 | password?: string; 20 | database?: string; 21 | query?: string; 22 | pushToken?: string; 23 | [key: string]: string | number | boolean | undefined; 24 | } 25 | 26 | /** 27 | * 创建监控项 28 | */ 29 | export async function createMonitor( 30 | name: string, 31 | type: string, 32 | config: MonitorConfig, 33 | interval: number = 60, 34 | retries: number = 0, 35 | retryInterval: number = 60, 36 | resendInterval: number = 0, 37 | upsideDown: boolean = false, 38 | description?: string 39 | ) { 40 | return await prisma.monitor.create({ 41 | data: { 42 | name, 43 | type, 44 | config: config as Prisma.JsonObject, 45 | interval, 46 | retries, 47 | retryInterval, 48 | resendInterval, 49 | upsideDown, 50 | description 51 | } 52 | }); 53 | } 54 | 55 | /** 56 | * 获取所有监控项 57 | */ 58 | export async function getAllMonitors() { 59 | return await prisma.monitor.findMany({ 60 | orderBy: { 61 | createdAt: 'desc' 62 | } 63 | }); 64 | } 65 | 66 | /** 67 | * 根据ID获取监控项 68 | */ 69 | export async function getMonitorById(id: string) { 70 | return await prisma.monitor.findUnique({ 71 | where: { id } 72 | }); 73 | } 74 | 75 | /** 76 | * 更新监控项 77 | */ 78 | export async function updateMonitor( 79 | id: string, 80 | data: { 81 | name?: string; 82 | type?: string; 83 | config?: MonitorConfig; 84 | interval?: number; 85 | retries?: number; 86 | retryInterval?: number; 87 | resendInterval?: number; 88 | upsideDown?: boolean; 89 | active?: boolean; 90 | description?: string; 91 | } 92 | ) { 93 | return await prisma.monitor.update({ 94 | where: { id }, 95 | data: { 96 | ...data, 97 | config: data.config ? data.config as Prisma.JsonObject : undefined 98 | } 99 | }); 100 | } 101 | 102 | /** 103 | * 删除监控项 104 | */ 105 | export async function deleteMonitor(id: string) { 106 | return await prisma.monitor.delete({ 107 | where: { id } 108 | }); 109 | } 110 | 111 | /** 112 | * 添加监控状态记录 113 | */ 114 | export async function addStatusRecord( 115 | monitorId: string, 116 | success: boolean, 117 | responseTime?: number, 118 | message?: string 119 | ) { 120 | return await prisma.monitorStatus.create({ 121 | data: { 122 | monitorId, 123 | status: success ? 1 : 0, // 状态:0=down, 1=up 124 | ping: responseTime, 125 | message 126 | } 127 | }); 128 | } 129 | 130 | /** 131 | * 生成用于Push监控的随机令牌 132 | * @returns 32位随机字符串 133 | */ 134 | export function generatePushToken(): string { 135 | const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; 136 | let token = ''; 137 | 138 | // 生成32位随机字符串 139 | for (let i = 0; i < 32; i++) { 140 | token += chars.charAt(Math.floor(Math.random() * chars.length)); 141 | } 142 | 143 | return token; 144 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 酷监控 | 高颜值的监控工具 2 | 3 | 酷监控是一个高颜值的监控工具,支持网站监控/接口监控/HTTPS证书监控等多种监控类型,帮助开发者及运维人员实时掌握网站/接口运行状态。本项目支持Docker一键快速部署,拥有美观现代的界面设计。 4 | 5 | 6 | ## 🚀 功能特点 7 | 8 | * **多种监控类型**:支持HTTP/HTTPS网站、API接口、HTTPS证书过期、TCP端口、MySQL/Redis数据库等多种监控 9 | * **推送监控**:支持被动接收客户端的心跳推送,实现不可直接访问设备的监控 10 | * **精美界面**:深色/浅色主题切换,响应式设计适配各种设备尺寸 11 | * **强大通知系统**:支持邮件、Webhook、微信通知渠道等多种通知方式 12 | * **数据可视化**:直观的状态图表和分析功能,快速了解系统运行状况 13 | * **持久化存储**:使用SQLite数据库,轻量级部署无需额外依赖 14 | 15 | ## 📸 截图预览 16 | 17 | ### 控制台主界面 18 | ![控制台主界面](./screenshot/dashboard-main.png) 19 | 20 | ### 单个监控项详情页 21 | ![监控详情页](./screenshot/dashboard-one.png) 22 | 23 | ### 添加监控 24 | ![添加监控](./screenshot/add.png) 25 | 26 | ### 通知设置 27 | ![通知设置](./screenshot/notification.png) 28 | 29 | ## 🔧 监控类型 30 | 31 | * **HTTP/HTTPS网站监控**:检查网站可用性和响应时间 32 | * **HTTPS证书监控**:检查SSL证书过期时间,提前预警 33 | * **关键词监控**:检查网页内容是否包含特定关键词 34 | * **TCP端口监控**:检查端口是否开放 35 | * **MySQL/MariaDB数据库监控**:检查数据库连接和基本查询 36 | * **Redis数据库监控**:检查Redis服务状态 37 | * **推送监控**:被动接收客户端的心跳推送 38 | 39 | ## 🛠️ 技术栈 40 | 41 | * **前端框架**:Next.js 42 | * **后端**:Next.js API Routes 43 | * **数据库**:SQLite (通过Prisma ORM) 44 | * **UI库**:TailwindCSS 45 | * **图表库**:ECharts 46 | * **认证**:NextAuth.js 47 | * **计划任务**:Croner 48 | 49 | ## 📦 安装与部署 50 | 51 | ### 使用Docker部署(推荐) 52 | 53 | 使用Docker是运行酷监控最简单的方式: 54 | 55 | ```bash 56 | # 适用于x86/x64架构 57 | docker run -d --name coolmonitor --restart always -p 3333:3333 -v ~/coolmonitor_data:/app/data star7th/coolmonitor:latest 58 | 59 | # 适用于ARM架构(如树莓派、Apple Silicon) 60 | docker run -d --name coolmonitor --restart always -p 3333:3333 -v ~/coolmonitor_data:/app/data star7th/coolmonitor:arm-latest 61 | ``` 62 | 63 | 64 | ### 初始化说明 65 | 66 | 首次启动时,系统会自动: 67 | 1. 检查数据库是否存在 68 | - 如果存在预置的数据库,则直接使用 69 | - 如果不存在,则自动初始化数据库结构 70 | 2. 首次访问时,系统会引导你创建管理员账户 71 | 72 | 访问 http://localhost:3333 开始使用酷监控。 73 | 74 | ## 🔄 更新说明 75 | 76 | ### Docker部署更新 77 | 78 | 如果您使用Docker部署,更新到最新版本需要执行以下步骤: 79 | 80 | ```bash 81 | # 1. 停止当前运行的容器 82 | docker stop coolmonitor 83 | 84 | # 2. 删除旧容器(数据会保留在挂载的卷中) 85 | docker rm coolmonitor 86 | 87 | # 3. 拉取最新镜像 88 | docker pull star7th/coolmonitor:latest 89 | # 或者对于ARM架构 90 | docker pull star7th/coolmonitor:arm-latest 91 | 92 | # 4. 重新运行容器 93 | docker run -d --name coolmonitor --restart always -p 3333:3333 -v ~/coolmonitor_data:/app/data star7th/coolmonitor:latest 94 | ``` 95 | 96 | **注意事项:** 97 | - 更新过程中,你的监控数据和配置会保留在挂载的数据卷中 98 | - 建议在更新前备份重要数据 99 | - 更新后首次启动可能需要几秒或者几十秒时间进行数据库迁移 100 | 101 | ## 🧩 项目结构 102 | 103 | ``` 104 | coolmonitor/ 105 | ├── src/ 106 | │ ├── app/ - Next.js应用目录 107 | │ │ ├── dashboard/ - 监控面板 108 | │ │ ├── auth/ - 用户认证 109 | │ │ └── api/ - API接口 110 | │ ├── components/ - 可复用组件 111 | │ ├── lib/ - 工具函数和库 112 | │ │ ├── monitors/ - 监控检查器实现 113 | │ │ ├── database-upgrader.ts - 数据库升级工具 114 | │ │ └── system-init.ts - 系统初始化 115 | │ ├── hooks/ - 自定义Hook 116 | │ ├── context/ - React上下文 117 | │ └── types/ - TypeScript类型定义 118 | └── prisma/ - 数据库模型和迁移 119 | ``` 120 | 121 | ## 🌍 贡献指南 122 | 123 | 欢迎贡献代码!请随时提交Pull Request。 124 | 125 | 1. Fork仓库 126 | 2. 创建功能分支 (`git checkout -b feature/amazing-feature`) 127 | 3. 提交更改 (`git commit -m '添加某项惊人功能'`) 128 | 4. 推送到分支 (`git push origin feature/amazing-feature`) 129 | 5. 打开Pull Request 130 | 131 | ## 📄 许可证 132 | 133 | 本项目基于Apache License 2.0许可证开源 - 详情请查看 LICENSE 文件。 134 | 135 | ## 🔗 链接 136 | 137 | * GitHub仓库: https://github.com/star7th/coolmonitor 138 | -------------------------------------------------------------------------------- /src/app/api/monitors/template/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import * as XLSX from 'xlsx'; 3 | import { validateAuth } from '@/lib/auth-helpers'; 4 | 5 | // GET /api/monitors/template - 下载Excel模板 6 | export async function GET() { 7 | try { 8 | // 验证用户是否已登录 9 | const authError = await validateAuth(); 10 | if (authError) return authError; 11 | 12 | // 创建模板数据 13 | const templateData = [ 14 | { 15 | '监控名称': '示例网站监控', 16 | '监控类型': 'http', 17 | 'URL': 'https://example.com', 18 | '主机名': '', 19 | '端口': '', 20 | '关键字': '', 21 | '检查间隔(秒)': 60, 22 | '重试次数': 0, 23 | '重试间隔(秒)': 60, 24 | '重发间隔(秒)': 0, 25 | '是否启用': true, 26 | '反向监控': false, 27 | '描述': '示例监控项', 28 | '分组名称': '', 29 | 'HTTP方法': 'GET', 30 | '状态码范围': '200-299', 31 | '最大重定向次数': 10, 32 | '连接超时(秒)': 10, 33 | '忽略TLS错误': false, 34 | '通知证书到期': false, 35 | '用户名': '', 36 | '密码': '', 37 | '数据库名': '', 38 | '查询语句': '' 39 | }, 40 | { 41 | '监控名称': '示例端口监控', 42 | '监控类型': 'port', 43 | 'URL': '', 44 | '主机名': 'example.com', 45 | '端口': 80, 46 | '关键字': '', 47 | '检查间隔(秒)': 60, 48 | '重试次数': 0, 49 | '重试间隔(秒)': 60, 50 | '重发间隔(秒)': 0, 51 | '是否启用': true, 52 | '反向监控': false, 53 | '描述': '', 54 | '分组名称': '', 55 | 'HTTP方法': '', 56 | '状态码范围': '', 57 | '最大重定向次数': '', 58 | '连接超时(秒)': '', 59 | '忽略TLS错误': '', 60 | '通知证书到期': '', 61 | '用户名': '', 62 | '密码': '', 63 | '数据库名': '', 64 | '查询语句': '' 65 | } 66 | ]; 67 | 68 | // 创建Excel工作簿 69 | const workbook = XLSX.utils.book_new(); 70 | const worksheet = XLSX.utils.json_to_sheet(templateData); 71 | 72 | // 设置列宽 73 | const columnWidths = [ 74 | { wch: 20 }, // 监控名称 75 | { wch: 12 }, // 监控类型 76 | { wch: 30 }, // URL 77 | { wch: 20 }, // 主机名 78 | { wch: 8 }, // 端口 79 | { wch: 15 }, // 关键字 80 | { wch: 15 }, // 检查间隔 81 | { wch: 10 }, // 重试次数 82 | { wch: 15 }, // 重试间隔 83 | { wch: 15 }, // 重发间隔 84 | { wch: 10 }, // 是否启用 85 | { wch: 10 }, // 反向监控 86 | { wch: 20 }, // 描述 87 | { wch: 15 }, // 分组名称 88 | { wch: 12 }, // HTTP方法 89 | { wch: 15 }, // 状态码范围 90 | { wch: 15 }, // 最大重定向次数 91 | { wch: 15 }, // 连接超时 92 | { wch: 12 }, // 忽略TLS错误 93 | { wch: 15 }, // 通知证书到期 94 | { wch: 15 }, // 用户名 95 | { wch: 15 }, // 密码 96 | { wch: 15 }, // 数据库名 97 | { wch: 20 } // 查询语句 98 | ]; 99 | worksheet['!cols'] = columnWidths; 100 | 101 | // 添加工作表到工作簿 102 | XLSX.utils.book_append_sheet(workbook, worksheet, '监控项模板'); 103 | 104 | // 生成Excel文件缓冲区 105 | const excelBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }); 106 | 107 | // 返回文件 108 | const filename = encodeURIComponent('监控项导入模板.xlsx'); 109 | return new NextResponse(excelBuffer, { 110 | headers: { 111 | 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 112 | 'Content-Disposition': `attachment; filename*=UTF-8''${filename}` 113 | } 114 | }); 115 | } catch (error) { 116 | console.error('生成模板失败:', error); 117 | return NextResponse.json( 118 | { error: '生成模板失败,请稍后重试' }, 119 | { status: 500 } 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/lib/monitors/checker-ports.ts: -------------------------------------------------------------------------------- 1 | import net from 'net'; 2 | import { MonitorPortConfig, MonitorCheckResult, MONITOR_STATUS, ERROR_MESSAGES } from './types'; 3 | 4 | /** 5 | * 检查TCP端口是否开放(单次执行,不包含重试逻辑) 6 | * @param config 端口监控配置 7 | * @returns 检查结果 8 | */ 9 | async function checkPortSingle(config: MonitorPortConfig): Promise { 10 | const startTime = Date.now(); 11 | const { hostname, port } = config; 12 | const portNumber = typeof port === 'string' ? parseInt(port) : port; 13 | 14 | return new Promise((resolve) => { 15 | try { 16 | const socket = new net.Socket(); 17 | let resolved = false; 18 | 19 | // 设置超时 20 | socket.setTimeout(10000); 21 | 22 | socket.on('connect', () => { 23 | if (resolved) return; 24 | resolved = true; 25 | socket.destroy(); 26 | resolve({ 27 | status: MONITOR_STATUS.UP, 28 | message: `端口开放`, 29 | ping: Date.now() - startTime 30 | }); 31 | }); 32 | 33 | socket.on('timeout', () => { 34 | if (resolved) return; 35 | resolved = true; 36 | socket.destroy(); 37 | resolve({ 38 | status: MONITOR_STATUS.DOWN, 39 | message: ERROR_MESSAGES.TIMEOUT, 40 | ping: Date.now() - startTime 41 | }); 42 | }); 43 | 44 | socket.on('error', (error: NodeJS.ErrnoException) => { 45 | if (resolved) return; 46 | resolved = true; 47 | socket.destroy(); 48 | 49 | let message = ERROR_MESSAGES.UNKNOWN_ERROR; 50 | if (error.code === 'ECONNREFUSED') { 51 | message = ERROR_MESSAGES.CONNECTION_REFUSED; 52 | } else if (error.code === 'ETIMEDOUT') { 53 | message = ERROR_MESSAGES.TIMEOUT; 54 | } else if (error.code === 'ENOTFOUND') { 55 | message = ERROR_MESSAGES.HOST_NOT_FOUND; 56 | } else { 57 | message = `${ERROR_MESSAGES.NETWORK_ERROR}: ${error.message}`; 58 | } 59 | 60 | resolve({ 61 | status: MONITOR_STATUS.DOWN, 62 | message, 63 | ping: Date.now() - startTime 64 | }); 65 | }); 66 | 67 | // 开始连接 68 | socket.connect(portNumber, hostname); 69 | } catch (error) { 70 | resolve({ 71 | status: MONITOR_STATUS.DOWN, 72 | message: `${ERROR_MESSAGES.UNKNOWN_ERROR}: ${error instanceof Error ? error.message : String(error)}`, 73 | ping: null 74 | }); 75 | } 76 | }); 77 | } 78 | 79 | /** 80 | * 检查TCP端口是否开放(包含重试逻辑) 81 | * @param config 端口监控配置 82 | * @returns 检查结果 83 | */ 84 | export async function checkPort(config: MonitorPortConfig): Promise { 85 | const { retries = 0, retryInterval = 60 } = config; 86 | 87 | // 如果没有设置重试次数,直接执行单次检查 88 | if (retries === 0) { 89 | return await checkPortSingle(config); 90 | } 91 | 92 | // 执行首次检查 93 | const result = await checkPortSingle(config); 94 | 95 | // 如果首次检查成功,直接返回 96 | if (result.status === MONITOR_STATUS.UP) { 97 | return result; 98 | } 99 | 100 | // 如果配置了重试次数且首次检查失败,进行重试 101 | if (retries > 0) { 102 | for (let i = 0; i < retries; i++) { 103 | // 等待重试间隔时间(秒) 104 | await new Promise(resolve => setTimeout(resolve, retryInterval * 1000)); 105 | 106 | // 执行重试检查 107 | const retryResult = await checkPortSingle(config); 108 | 109 | if (retryResult.status === MONITOR_STATUS.UP) { 110 | return { 111 | ...retryResult, 112 | message: `重试成功 (${i + 1}/${retries}): ${retryResult.message}` 113 | }; 114 | } 115 | } 116 | 117 | return { 118 | ...result, 119 | message: `重试${retries}次后仍然失败: ${result.message}` 120 | }; 121 | } 122 | 123 | return result; 124 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 构建阶段 2 | FROM node:20-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | # 使用国内npm镜像源 7 | RUN npm config set registry https://registry.npmmirror.com 8 | 9 | # 复制依赖文件 10 | COPY package.json package-lock.json* ./ 11 | 12 | # 安装依赖 13 | RUN npm ci 14 | 15 | # 复制源代码 16 | COPY . . 17 | 18 | # 生成Prisma客户端 19 | RUN npx prisma generate 20 | 21 | # 预先生成SQLite数据库,并确保创建成功 22 | RUN mkdir -p /app/prisma/template && \ 23 | echo "正在生成数据库模板..." && \ 24 | npx prisma migrate deploy && \ 25 | DB_FILE=$(find /app -name "*.db" -type f | head -n 1) && \ 26 | if [ -n "$DB_FILE" ]; then \ 27 | echo "找到数据库文件: $DB_FILE" && \ 28 | cp "$DB_FILE" /app/prisma/template/coolmonitor.db && \ 29 | echo "数据库模板已保存到: /app/prisma/template/coolmonitor.db"; \ 30 | else \ 31 | echo "警告: 未找到数据库文件!" && \ 32 | touch /app/prisma/template/coolmonitor.db && \ 33 | echo "已创建空的数据库模板文件"; \ 34 | fi 35 | 36 | # 构建应用 37 | RUN npm run build 38 | 39 | # 检查并确保Prisma客户端正确包含在构建产物中 40 | RUN echo "检查standalone目录中的Prisma客户端..." && \ 41 | if [ ! -d "/app/.next/standalone/node_modules/.prisma" ] || [ ! -d "/app/.next/standalone/node_modules/@prisma/client" ]; then \ 42 | echo "Prisma客户端不完整,正在手动复制..." && \ 43 | mkdir -p /app/.next/standalone/node_modules/.prisma && \ 44 | cp -r /app/node_modules/.prisma/* /app/.next/standalone/node_modules/.prisma/ 2>/dev/null || echo "复制.prisma目录失败" && \ 45 | mkdir -p /app/.next/standalone/node_modules/@prisma/client && \ 46 | cp -r /app/node_modules/@prisma/client/* /app/.next/standalone/node_modules/@prisma/client/ 2>/dev/null || echo "复制@prisma/client目录失败"; \ 47 | else \ 48 | echo "✓ Prisma客户端已正确包含在构建产物中"; \ 49 | fi 50 | 51 | # 中间阶段 - 提取相关文件并删除不必要的依赖 52 | FROM node:20-alpine AS extractor 53 | 54 | WORKDIR /app 55 | 56 | COPY --from=builder /app/.next/standalone ./ 57 | COPY --from=builder /app/.next/static ./.next/static 58 | COPY --from=builder /app/public ./public 59 | COPY --from=builder /app/prisma/template ./prisma/template 60 | COPY --from=builder /app/startup.sh ./startup.sh 61 | 62 | # 确认Prisma客户端存在 63 | RUN echo "确认Prisma客户端目录存在..." && \ 64 | if [ -d "./node_modules/.prisma" ] && [ -d "./node_modules/@prisma/client" ]; then \ 65 | echo "✓ Prisma客户端已成功复制"; \ 66 | else \ 67 | echo "✗ Prisma客户端缺失,构建可能有问题!"; \ 68 | exit 1; \ 69 | fi 70 | 71 | # 清理不必要的文件和目录 72 | RUN find . -name "*.map" -type f -delete && \ 73 | find . -path "*/node_modules/.bin/*" -delete && \ 74 | find ./node_modules -name "README*" -delete && \ 75 | find ./node_modules -name "readme*" -delete && \ 76 | find ./node_modules -name "CHANGELOG*" -delete && \ 77 | find ./node_modules -name "LICENSE*" -delete && \ 78 | find ./node_modules -name "*.d.ts" -delete && \ 79 | find ./node_modules -path "*/test/*" -delete && \ 80 | find ./node_modules -path "*/tests/*" -delete && \ 81 | find ./node_modules -path "*/.github/*" -delete 82 | 83 | # 生产阶段 84 | FROM alpine:3.19 AS runner 85 | 86 | # 安装Node.js运行时,不安装npm等开发工具 87 | RUN apk add --no-cache nodejs 88 | 89 | WORKDIR /app 90 | 91 | # 安装dos2unix进行行尾处理 92 | RUN apk add --no-cache dos2unix 93 | 94 | # 复制应用文件 95 | COPY --from=extractor /app ./ 96 | 97 | # 处理启动脚本 98 | RUN dos2unix ./startup.sh && \ 99 | chmod +x ./startup.sh && \ 100 | apk del --purge dos2unix 101 | 102 | # 创建数据目录并设置权限 103 | RUN mkdir -p /app/data 104 | 105 | # 使用root用户运行,不再创建非root用户 106 | # 注释掉创建用户的命令 107 | # RUN addgroup -S nodejs && \ 108 | # adduser -S nextjs -G nodejs && \ 109 | # mkdir -p /app/data && \ 110 | # chown -R nextjs:nodejs /app 111 | 112 | # 注释掉切换用户的命令 113 | # USER nextjs 114 | 115 | EXPOSE 3333 116 | 117 | ENV PORT=3333 118 | ENV HOSTNAME="0.0.0.0" 119 | ENV NODE_ENV=production 120 | 121 | # 数据卷 - 用于SQLite数据库文件 122 | VOLUME ["/app/data"] 123 | 124 | # 启动应用 125 | CMD ["./startup.sh"] -------------------------------------------------------------------------------- /src/app/api/settings/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { 3 | getAllGeneralSettings, 4 | getAllProxySettings, 5 | updateSettings, 6 | resetSettings, 7 | SETTINGS_KEYS 8 | } from '@/lib/settings'; 9 | import { validateAuth } from '@/lib/auth-helpers'; 10 | 11 | // 获取设置 12 | export async function GET(request: NextRequest) { 13 | try { 14 | // 验证用户是否已登录 15 | const authError = await validateAuth(); 16 | if (authError) return authError; 17 | 18 | const searchParams = request.nextUrl.searchParams; 19 | const section = searchParams.get('section'); 20 | 21 | let settings = {}; 22 | 23 | if (section === 'general') { 24 | settings = await getAllGeneralSettings(); 25 | } else if (section === 'proxy') { 26 | settings = await getAllProxySettings(); 27 | } else { 28 | // 获取所有设置 29 | const generalSettings = await getAllGeneralSettings(); 30 | const proxySettings = await getAllProxySettings(); 31 | settings = { 32 | ...generalSettings, 33 | ...proxySettings 34 | }; 35 | } 36 | 37 | return NextResponse.json({ success: true, data: settings }); 38 | } catch (error) { 39 | console.error('获取设置失败:', error); 40 | return NextResponse.json( 41 | { success: false, error: '获取设置失败' }, 42 | { status: 500 } 43 | ); 44 | } 45 | } 46 | 47 | // 更新设置 48 | export async function POST(request: NextRequest) { 49 | try { 50 | // 验证用户是否已登录 51 | const authError = await validateAuth(); 52 | if (authError) return authError; 53 | 54 | const body = await request.json(); 55 | 56 | console.log('收到设置更新请求:', body); 57 | 58 | if (!body || typeof body !== 'object') { 59 | return NextResponse.json( 60 | { success: false, error: '无效的请求数据' }, 61 | { status: 400 } 62 | ); 63 | } 64 | 65 | // 处理代理启用设置 - 确保正确转换布尔值 66 | if (SETTINGS_KEYS.PROXY_ENABLED in body) { 67 | const proxyEnabledValue = body[SETTINGS_KEYS.PROXY_ENABLED]; 68 | console.log('原始代理启用值:', proxyEnabledValue, typeof proxyEnabledValue); 69 | 70 | // 规范化代理启用值为字符串 'true' 或 'false' 71 | if (typeof proxyEnabledValue === 'boolean') { 72 | body[SETTINGS_KEYS.PROXY_ENABLED] = proxyEnabledValue ? 'true' : 'false'; 73 | } else if (typeof proxyEnabledValue === 'string') { 74 | body[SETTINGS_KEYS.PROXY_ENABLED] = 75 | proxyEnabledValue.toLowerCase() === 'true' ? 'true' : 'false'; 76 | } else { 77 | body[SETTINGS_KEYS.PROXY_ENABLED] = 'false'; 78 | } 79 | 80 | console.log('处理后的代理启用值:', body[SETTINGS_KEYS.PROXY_ENABLED]); 81 | } 82 | 83 | // 对其他值进行字符串转换 84 | Object.keys(body).forEach(key => { 85 | if (body[key] === undefined || body[key] === null) { 86 | body[key] = ''; 87 | } else if (typeof body[key] !== 'string') { 88 | body[key] = String(body[key]); 89 | } 90 | }); 91 | 92 | console.log('规范化后的设置数据:', body); 93 | 94 | await updateSettings(body); 95 | 96 | return NextResponse.json({ 97 | success: true, 98 | message: '设置已更新', 99 | updatedSettings: body 100 | }); 101 | } catch (error) { 102 | console.error('更新设置失败:', error); 103 | return NextResponse.json( 104 | { success: false, error: '更新设置失败' }, 105 | { status: 500 } 106 | ); 107 | } 108 | } 109 | 110 | // 重置所有设置 111 | export async function DELETE() { 112 | try { 113 | // 验证用户是否已登录 114 | const authError = await validateAuth(); 115 | if (authError) return authError; 116 | 117 | await resetSettings(); 118 | return NextResponse.json({ success: true }); 119 | } catch (error) { 120 | console.error('重置设置失败:', error); 121 | return NextResponse.json( 122 | { success: false, error: '重置设置失败' }, 123 | { status: 500 } 124 | ); 125 | } 126 | } -------------------------------------------------------------------------------- /src/app/api/status-pages/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { prisma } from '@/lib/prisma'; 3 | import { getServerSession } from 'next-auth'; 4 | import { authOptions } from '@/lib/auth'; 5 | 6 | // 获取单个状态页详情 7 | export async function GET( 8 | request: NextRequest, 9 | { params }: { params: Promise<{ id: string }> } 10 | ) { 11 | try { 12 | const { id } = await params; 13 | 14 | const session = await getServerSession(authOptions); 15 | if (!session?.user) { 16 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 17 | } 18 | 19 | const statusPage = await prisma.statusPage.findFirst({ 20 | where: { 21 | id, 22 | createdById: session.user.id 23 | }, 24 | include: { 25 | monitors: { 26 | include: { 27 | monitor: true 28 | }, 29 | orderBy: { 30 | order: 'asc' 31 | } 32 | } 33 | } 34 | }); 35 | 36 | if (!statusPage) { 37 | return NextResponse.json({ error: '状态页不存在' }, { status: 404 }); 38 | } 39 | 40 | return NextResponse.json(statusPage); 41 | } catch (error) { 42 | console.error('获取状态页详情失败:', error); 43 | return NextResponse.json({ error: '获取状态页详情失败' }, { status: 500 }); 44 | } 45 | } 46 | 47 | // 更新状态页 48 | export async function PUT( 49 | request: NextRequest, 50 | { params }: { params: Promise<{ id: string }> } 51 | ) { 52 | try { 53 | const { id } = await params; 54 | 55 | const session = await getServerSession(authOptions); 56 | if (!session?.user) { 57 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 58 | } 59 | 60 | const body = await request.json(); 61 | const { name, slug, title, isPublic } = body; 62 | 63 | // 检查状态页是否存在且属于当前用户 64 | const existingStatusPage = await prisma.statusPage.findFirst({ 65 | where: { 66 | id, 67 | createdById: session.user.id 68 | } 69 | }); 70 | 71 | if (!existingStatusPage) { 72 | return NextResponse.json({ error: '状态页不存在' }, { status: 404 }); 73 | } 74 | 75 | // 如果更新slug,检查是否与其他状态页冲突 76 | if (slug && slug !== existingStatusPage.slug) { 77 | const conflictStatusPage = await prisma.statusPage.findUnique({ 78 | where: { slug } 79 | }); 80 | 81 | if (conflictStatusPage) { 82 | return NextResponse.json({ error: 'URL标识符已存在' }, { status: 400 }); 83 | } 84 | } 85 | 86 | const updatedStatusPage = await prisma.statusPage.update({ 87 | where: { id }, 88 | data: { 89 | name, 90 | slug, 91 | title, 92 | isPublic 93 | } 94 | }); 95 | 96 | return NextResponse.json(updatedStatusPage); 97 | } catch (error) { 98 | console.error('更新状态页失败:', error); 99 | return NextResponse.json({ error: '更新状态页失败' }, { status: 500 }); 100 | } 101 | } 102 | 103 | // 删除状态页 104 | export async function DELETE( 105 | request: NextRequest, 106 | { params }: { params: Promise<{ id: string }> } 107 | ) { 108 | try { 109 | const { id } = await params; 110 | 111 | const session = await getServerSession(authOptions); 112 | if (!session?.user) { 113 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 114 | } 115 | 116 | // 检查状态页是否存在且属于当前用户 117 | const existingStatusPage = await prisma.statusPage.findFirst({ 118 | where: { 119 | id, 120 | createdById: session.user.id 121 | } 122 | }); 123 | 124 | if (!existingStatusPage) { 125 | return NextResponse.json({ error: '状态页不存在' }, { status: 404 }); 126 | } 127 | 128 | await prisma.statusPage.delete({ 129 | where: { id } 130 | }); 131 | 132 | return NextResponse.json({ message: '状态页删除成功' }); 133 | } catch (error) { 134 | console.error('删除状态页失败:', error); 135 | return NextResponse.json({ error: '删除状态页失败' }, { status: 500 }); 136 | } 137 | } -------------------------------------------------------------------------------- /src/app/dashboard/monitors/components/MonitorSettingsSection.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from "react"; 2 | 3 | interface MonitorSettingsSectionProps { 4 | interval: string; 5 | setInterval: Dispatch>; 6 | retries: string; 7 | setRetries: Dispatch>; 8 | retryInterval: string; 9 | setRetryInterval: Dispatch>; 10 | resendInterval: string; 11 | setResendInterval: Dispatch>; 12 | } 13 | 14 | export function MonitorSettingsSection({ 15 | interval, 16 | setInterval, 17 | retries, 18 | setRetries, 19 | retryInterval, 20 | setRetryInterval, 21 | resendInterval, 22 | setResendInterval 23 | }: MonitorSettingsSectionProps) { 24 | return ( 25 |
26 |

监控设置

27 |
28 | {/* 心跳间隔 */} 29 |
30 | 31 |
32 | setInterval(e.target.value)} 36 | className="w-full px-4 py-2 rounded-l-lg dark:bg-dark-input bg-light-input border border-r-0 border-primary/20 focus:border-primary focus:outline-none" 37 | min="1" 38 | /> 39 | 40 | 秒 41 | 42 |
43 |

监控检测的频率

44 |
45 | 46 | {/* 重试次数 */} 47 |
48 | 49 | setRetries(e.target.value)} 53 | className="w-full px-4 py-2 rounded-lg dark:bg-dark-input bg-light-input border border-primary/20 focus:border-primary focus:outline-none" 54 | min="0" 55 | /> 56 |

标记为故障前的最大重试次数

57 |
58 |
59 | 60 |
61 | {/* 心跳重试间隔 */} 62 |
63 | 64 |
65 | setRetryInterval(e.target.value)} 69 | className="w-full px-4 py-2 rounded-l-lg dark:bg-dark-input bg-light-input border border-r-0 border-primary/20 focus:border-primary focus:outline-none" 70 | min="1" 71 | /> 72 | 73 | 秒 74 | 75 |
76 |

重试检测的间隔时间

77 |
78 | 79 | {/* 连续失败发送通知间隔 */} 80 |
81 | 82 | setResendInterval(e.target.value)} 86 | className="w-full px-4 py-2 rounded-lg dark:bg-dark-input bg-light-input border border-primary/20 focus:border-primary focus:outline-none" 87 | min="0" 88 | /> 89 |

90 | {parseInt(resendInterval) > 0 91 | ? `每 ${resendInterval} 次连续失败时重新发送通知` 92 | : "禁用重复通知"} 93 |

94 |
95 |
96 |
97 | ); 98 | } -------------------------------------------------------------------------------- /src/app/api/status/[slug]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { prisma } from '@/lib/prisma'; 3 | 4 | // 获取公开状态页数据 5 | export async function GET( 6 | request: NextRequest, 7 | { params }: { params: Promise<{ slug: string }> } 8 | ) { 9 | try { 10 | const { slug } = await params; 11 | 12 | const statusPage = await prisma.statusPage.findUnique({ 13 | where: { 14 | slug, 15 | isPublic: true 16 | }, 17 | include: { 18 | monitors: { 19 | include: { 20 | monitor: { 21 | select: { 22 | id: true, 23 | name: true, 24 | type: true, 25 | active: true, 26 | lastStatus: true, 27 | lastCheckAt: true, 28 | description: true 29 | } 30 | } 31 | }, 32 | orderBy: { 33 | order: 'asc' 34 | } 35 | } 36 | } 37 | }); 38 | 39 | if (!statusPage) { 40 | return NextResponse.json({ error: '状态页不存在或未公开' }, { status: 404 }); 41 | } 42 | 43 | // 格式化监控项数据 44 | const monitors = statusPage.monitors.map(spm => ({ 45 | id: spm.monitor.id, 46 | name: spm.displayName || spm.monitor.name, 47 | type: spm.monitor.type, 48 | status: spm.monitor.lastStatus, 49 | active: spm.monitor.active, 50 | lastCheckAt: spm.monitor.lastCheckAt, 51 | description: spm.monitor.description 52 | })); 53 | 54 | // 计算状态统计 55 | const totalMonitors = monitors.length; 56 | const normalCount = monitors.filter(m => m.active && m.status === 1).length; 57 | const errorCount = monitors.filter(m => m.active && m.status === 0).length; 58 | const pausedCount = monitors.filter(m => !m.active).length; 59 | const unknownCount = totalMonitors - normalCount - errorCount - pausedCount; 60 | 61 | // 计算24小时成功率 62 | const now = new Date(); 63 | const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); 64 | 65 | const uptimeData = await Promise.all( 66 | monitors.map(async (monitor) => { 67 | if (!monitor.active) return { uptime: 0, totalChecks: 0 }; 68 | 69 | // 获取24小时内的检查记录 70 | const checks = await prisma.monitorStatus.findMany({ 71 | where: { 72 | monitorId: monitor.id, 73 | timestamp: { 74 | gte: twentyFourHoursAgo 75 | } 76 | }, 77 | select: { 78 | status: true 79 | } 80 | }); 81 | 82 | if (checks.length === 0) return { uptime: 0, totalChecks: 0 }; 83 | 84 | const successfulChecks = checks.filter(check => check.status === 1).length; 85 | const uptime = (successfulChecks / checks.length) * 100; 86 | 87 | return { uptime, totalChecks: checks.length }; 88 | }) 89 | ); 90 | 91 | // 计算总体成功率 92 | const totalChecks = uptimeData.reduce((sum, data) => sum + data.totalChecks, 0); 93 | const totalSuccessfulChecks = uptimeData.reduce((sum, data) => { 94 | return sum + Math.round((data.uptime / 100) * data.totalChecks); 95 | }, 0); 96 | 97 | const overallUptime = totalChecks > 0 ? (totalSuccessfulChecks / totalChecks) * 100 : 0; 98 | 99 | const statusData = { 100 | id: statusPage.id, 101 | name: statusPage.name, 102 | title: statusPage.title, 103 | slug: statusPage.slug, 104 | monitors, 105 | statistics: { 106 | total: totalMonitors, 107 | normal: normalCount, 108 | error: errorCount, 109 | paused: pausedCount, 110 | unknown: unknownCount, 111 | uptime: Math.round(overallUptime * 100) / 100 // 保留两位小数 112 | }, 113 | lastUpdated: new Date().toISOString() 114 | }; 115 | 116 | return NextResponse.json(statusData); 117 | } catch (error) { 118 | console.error('获取状态页数据失败:', error); 119 | return NextResponse.json({ error: '获取状态页数据失败' }, { status: 500 }); 120 | } 121 | } -------------------------------------------------------------------------------- /src/app/api/settings/notifications/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { 3 | getNotificationChannelById, 4 | updateNotificationChannel, 5 | deleteNotificationChannel, 6 | toggleNotificationChannelEnabled 7 | } from '@/lib/settings'; 8 | import { validateAuth } from '@/lib/auth-helpers'; 9 | 10 | // 获取单个通知渠道 11 | export async function GET( 12 | request: NextRequest, 13 | { params }: { params: { id: string } } 14 | ) { 15 | try { 16 | // 验证用户是否已登录 17 | const authError = await validateAuth(); 18 | if (authError) return authError; 19 | 20 | const id = params.id; 21 | 22 | if (!id) { 23 | return NextResponse.json( 24 | { success: false, error: '缺少通知渠道ID' }, 25 | { status: 400 } 26 | ); 27 | } 28 | 29 | const channel = await getNotificationChannelById(id); 30 | 31 | if (!channel) { 32 | return NextResponse.json( 33 | { success: false, error: '找不到指定的通知渠道' }, 34 | { status: 404 } 35 | ); 36 | } 37 | 38 | return NextResponse.json({ success: true, data: channel }); 39 | } catch (error) { 40 | console.error('获取通知渠道详情失败:', error); 41 | return NextResponse.json( 42 | { success: false, error: '获取通知渠道详情失败' }, 43 | { status: 500 } 44 | ); 45 | } 46 | } 47 | 48 | // 更新通知渠道 49 | export async function PUT( 50 | request: NextRequest, 51 | { params }: { params: { id: string } } 52 | ) { 53 | try { 54 | // 验证用户是否已登录 55 | const authError = await validateAuth(); 56 | if (authError) return authError; 57 | 58 | const id = params.id; 59 | const body = await request.json(); 60 | 61 | if (!id) { 62 | return NextResponse.json( 63 | { success: false, error: '缺少通知渠道ID' }, 64 | { status: 400 } 65 | ); 66 | } 67 | 68 | if (!body || !body.name || !body.type || !body.config) { 69 | return NextResponse.json( 70 | { success: false, error: '缺少必要的字段' }, 71 | { status: 400 } 72 | ); 73 | } 74 | 75 | const channel = await updateNotificationChannel(id, { 76 | name: body.name, 77 | type: body.type, 78 | enabled: body.enabled, 79 | defaultForNewMonitors: body.defaultForNewMonitors, 80 | config: body.config 81 | }); 82 | 83 | if (!channel) { 84 | return NextResponse.json( 85 | { success: false, error: '找不到指定的通知渠道' }, 86 | { status: 404 } 87 | ); 88 | } 89 | 90 | return NextResponse.json({ success: true, data: channel }); 91 | } catch (error) { 92 | console.error('更新通知渠道失败:', error); 93 | return NextResponse.json( 94 | { success: false, error: '更新通知渠道失败' }, 95 | { status: 500 } 96 | ); 97 | } 98 | } 99 | 100 | // 删除通知渠道 101 | export async function DELETE( 102 | request: NextRequest, 103 | { params }: { params: { id: string } } 104 | ) { 105 | try { 106 | // 验证用户是否已登录 107 | const authError = await validateAuth(); 108 | if (authError) return authError; 109 | 110 | const id = params.id; 111 | await deleteNotificationChannel(id); 112 | 113 | return NextResponse.json({ success: true }); 114 | } catch (error) { 115 | console.error('删除通知渠道失败:', error); 116 | return NextResponse.json( 117 | { success: false, error: '删除通知渠道失败' }, 118 | { status: 500 } 119 | ); 120 | } 121 | } 122 | 123 | // 切换通知渠道启用状态 124 | export async function PATCH( 125 | request: NextRequest, 126 | { params }: { params: { id: string } } 127 | ) { 128 | try { 129 | // 验证用户是否已登录 130 | const authError = await validateAuth(); 131 | if (authError) return authError; 132 | 133 | const id = params.id; 134 | const updatedChannel = await toggleNotificationChannelEnabled(id); 135 | 136 | return NextResponse.json({ success: true, data: updatedChannel }); 137 | } catch (error) { 138 | console.error('切换通知渠道状态失败:', error); 139 | return NextResponse.json( 140 | { success: false, error: '切换通知渠道状态失败' }, 141 | { status: 500 } 142 | ); 143 | } 144 | } -------------------------------------------------------------------------------- /src/lib/utils/ultra-compact-id.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ID生成器 - 使用标准UUID策略 3 | * 为了确保唯一性和稳定性,使用标准的UUID v4生成方式 4 | * 不再追求极致的紧凑性,而是优先保证无冲突 5 | */ 6 | 7 | import { randomUUID } from 'crypto'; 8 | 9 | /** 10 | * 生成标准UUID v4 11 | * 使用Node.js内置的crypto.randomUUID(),确保高质量随机性 12 | */ 13 | export function generateUUID(): string { 14 | return randomUUID(); 15 | } 16 | 17 | /** 18 | * 生成短UUID(22位) 19 | * 基于UUID v4,但使用base64编码压缩长度 20 | * 保持UUID的唯一性,但长度更短 21 | */ 22 | export function generateShortUUID(): string { 23 | const uuid = randomUUID(); 24 | // 移除连字符 25 | const cleanUuid = uuid.replace(/-/g, ''); 26 | // 转换为base64,移除填充字符 27 | const base64 = Buffer.from(cleanUuid, 'hex').toString('base64'); 28 | // 移除base64中的+和/,替换为URL安全的字符 29 | return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); 30 | } 31 | 32 | /** 33 | * 生成紧凑UUID(16位) 34 | * 使用时间戳+随机数的组合,但增加更多随机性 35 | * 格式:TTTTTTTT + RRRRRRRR 36 | * - T: 8位时间戳(基于相对时间) 37 | * - R: 8位随机数 38 | */ 39 | export function generateCompactUUID(): string { 40 | const BASE_TIME = new Date('2024-01-01T00:00:00Z').getTime(); 41 | const now = Date.now(); 42 | const relativeTime = now - BASE_TIME; 43 | 44 | // 8位时间戳(36进制) 45 | const timeCode = relativeTime.toString(36).padStart(8, '0').slice(-8); 46 | 47 | // 8位随机数(36进制) 48 | const randomCode = Math.random().toString(36).substring(2, 10); 49 | 50 | return timeCode + randomCode; 51 | } 52 | 53 | /** 54 | * 默认ID生成器 55 | * 使用标准UUID,确保最大兼容性和唯一性 56 | */ 57 | export function generateUltraCompactId(): string { 58 | return generateUUID(); 59 | } 60 | 61 | /** 62 | * 兼容性函数 - 保持原有接口 63 | */ 64 | export function generateUltraCompactId6(): string { 65 | return generateCompactUUID(); 66 | } 67 | 68 | export function generateUltraCompactId7(): string { 69 | return generateCompactUUID(); 70 | } 71 | 72 | export function generateUltraCompactId8(): string { 73 | return generateShortUUID(); 74 | } 75 | 76 | export function generateUltraCompactId9(): string { 77 | return generateShortUUID(); 78 | } 79 | 80 | export function generateUltraCompactIdWithRetry(): string { 81 | // 对于UUID,理论上不需要重试,但保留接口兼容性 82 | return generateUUID(); 83 | } 84 | 85 | /** 86 | * 验证ID格式 87 | */ 88 | export function isUltraCompactId(id: string): boolean { 89 | // 支持多种格式:UUID、短UUID、紧凑UUID 90 | return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) || // 标准UUID 91 | /^[0-9a-zA-Z_-]{22}$/.test(id) || // 短UUID 92 | /^[0-9a-z]{16}$/.test(id); // 紧凑UUID 93 | } 94 | 95 | /** 96 | * 从ID中提取时间信息(仅对紧凑UUID有效) 97 | */ 98 | export function extractTimeFromUltraCompactId(id: string): Date | null { 99 | if (!isUltraCompactId(id)) { 100 | return null; 101 | } 102 | 103 | try { 104 | // 如果是紧凑UUID格式 105 | if (/^[0-9a-z]{16}$/.test(id)) { 106 | const BASE_TIME = new Date('2024-01-01T00:00:00Z').getTime(); 107 | const timeCode = id.substring(0, 8); 108 | const relativeTime = parseInt(timeCode, 36); 109 | return new Date(BASE_TIME + relativeTime); 110 | } 111 | 112 | // 如果是标准UUID,无法提取时间信息 113 | return null; 114 | } catch { 115 | return null; 116 | } 117 | } 118 | 119 | /** 120 | * 计算ID碰撞风险(对于UUID,风险极低) 121 | */ 122 | export function calculateCollisionRisk(idLength: number, recordsPerDay: number): { 123 | dailyRisk: number; 124 | yearlyRisk: number; 125 | threeYearRisk: number; 126 | } { 127 | // UUID的碰撞概率极低,几乎可以忽略 128 | const uuidSpace = Math.pow(2, 122); // UUID v4的有效位数 129 | const dailyCombinations = recordsPerDay * (recordsPerDay - 1) / 2; 130 | 131 | // 使用生日悖论公式 132 | const dailyRisk = 1 - Math.exp(-dailyCombinations / uuidSpace); 133 | const yearlyRisk = 1 - Math.pow(1 - dailyRisk, 365); 134 | const threeYearRisk = 1 - Math.pow(1 - dailyRisk, 365 * 3); 135 | 136 | return { 137 | dailyRisk: dailyRisk * 100, 138 | yearlyRisk: yearlyRisk * 100, 139 | threeYearRisk: threeYearRisk * 100 140 | }; 141 | } 142 | 143 | /** 144 | * 获取生成器统计信息 145 | */ 146 | export function getGeneratorStats(): { 147 | cacheSize: number; 148 | maxCacheSize: number; 149 | collisionRate: number; 150 | } { 151 | return { 152 | cacheSize: 0, // UUID不需要缓存 153 | maxCacheSize: 0, 154 | collisionRate: 0 // UUID碰撞率极低 155 | }; 156 | } 157 | -------------------------------------------------------------------------------- /src/tests/monitors/checker-push.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { checkPush } from '../../lib/monitors/checker-push'; 3 | import { MONITOR_STATUS } from '../../lib/monitors/types'; 4 | 5 | describe('推送监控检查器测试', () => { 6 | // 保存原始的Date构造函数和Date.now方法 7 | const RealDate = global.Date; 8 | const realNow = Date.now; 9 | 10 | beforeEach(() => { 11 | vi.clearAllMocks(); 12 | 13 | // 设置固定时间为2023-01-01 12:00:00 14 | const fixedTime = new RealDate('2023-01-01T12:00:00Z').getTime(); 15 | 16 | // 修改Date.now方法 17 | global.Date.now = vi.fn(() => fixedTime); 18 | }); 19 | 20 | afterEach(() => { 21 | vi.resetAllMocks(); 22 | 23 | // 恢复原始的Date.now方法 24 | global.Date.now = realNow; 25 | }); 26 | 27 | it('应当在配置无效时返回DOWN状态', async () => { 28 | // @ts-expect-error - 故意传入无效配置 29 | const result = await checkPush(null); 30 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 31 | expect(result.message).toContain('配置无效'); 32 | }); 33 | 34 | it('应当在缺少最后推送时间时返回PENDING状态', async () => { 35 | const result = await checkPush({ token: 'test-token' }); 36 | expect(result.status).toBe(MONITOR_STATUS.PENDING); 37 | expect(result.message).toContain('等待推送'); 38 | }); 39 | 40 | it('应当在最后推送时间无效时返回PENDING状态', async () => { 41 | const result = await checkPush({ 42 | token: 'test-token', 43 | lastPushTime: 'invalid-date' 44 | }); 45 | expect(result.status).toBe(MONITOR_STATUS.PENDING); 46 | expect(result.message).toContain('推送时间格式无效'); 47 | }); 48 | 49 | it('应当在最后推送时间在允许间隔内时返回UP状态', async () => { 50 | // 模拟最后推送时间为30秒前 51 | const lastPushTime = new RealDate('2023-01-01T11:59:30Z').toISOString(); 52 | 53 | const result = await checkPush({ 54 | token: 'test-token', 55 | lastPushTime, 56 | pushInterval: 60 // 允许60秒间隔 57 | }); 58 | 59 | expect(result.status).toBe(MONITOR_STATUS.UP); 60 | expect(result.message).toContain('最近推送时间'); 61 | }); 62 | 63 | it('应当在最后推送时间超过允许间隔时返回DOWN状态', async () => { 64 | // 模拟最后推送时间为90秒前 65 | const lastPushTime = new RealDate('2023-01-01T11:58:30Z').toISOString(); 66 | 67 | const result = await checkPush({ 68 | token: 'test-token', 69 | lastPushTime, 70 | pushInterval: 60 // 允许60秒间隔 71 | }); 72 | 73 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 74 | expect(result.message).toContain('推送超时'); 75 | }); 76 | 77 | it('应当在未指定pushInterval时使用默认值60秒', async () => { 78 | // 模拟最后推送时间为30秒前 79 | const lastPushTime = new RealDate('2023-01-01T11:59:30Z').toISOString(); 80 | 81 | const result = await checkPush({ 82 | token: 'test-token', 83 | lastPushTime 84 | // 未指定pushInterval,应使用默认值60秒 85 | }); 86 | 87 | expect(result.status).toBe(MONITOR_STATUS.UP); 88 | expect(result.message).toContain('最近推送时间'); 89 | }); 90 | 91 | it('应当在最后推送时间超过默认间隔时返回DOWN状态', async () => { 92 | // 模拟最后推送时间为120秒前 93 | const lastPushTime = new RealDate('2023-01-01T11:58:00Z').toISOString(); 94 | 95 | const result = await checkPush({ 96 | token: 'test-token', 97 | lastPushTime 98 | // 未指定pushInterval,应使用默认值60秒 99 | }); 100 | 101 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 102 | expect(result.message).toContain('推送超时'); 103 | }); 104 | 105 | it('应当正确计算并显示超时时间', async () => { 106 | // 模拟最后推送时间为90秒前 107 | const lastPushTime = new RealDate('2023-01-01T11:58:30Z').toISOString(); 108 | 109 | const result = await checkPush({ 110 | token: 'test-token', 111 | lastPushTime, 112 | pushInterval: 60 113 | }); 114 | 115 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 116 | expect(result.message).toContain('超时 1 分 30 秒'); 117 | }); 118 | 119 | it('应当在发生异常时返回DOWN状态', async () => { 120 | // 模拟一个异常 121 | global.Date.now = vi.fn(() => { 122 | throw new Error('测试错误'); 123 | }); 124 | 125 | const result = await checkPush({ 126 | token: 'test-token', 127 | lastPushTime: '2023-01-01T11:58:30Z' 128 | }); 129 | 130 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 131 | expect(result.message).toContain('测试错误'); 132 | }); 133 | }); -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { AuthOptions } from 'next-auth'; 2 | import CredentialsProvider from 'next-auth/providers/credentials'; 3 | import { verifyPassword, recordLoginAttempt } from '@/lib/auth'; 4 | import { getOrCreateJwtSecret } from '@/lib/system-config'; 5 | import { prisma } from '@/lib/prisma'; 6 | 7 | // 一年的秒数 8 | const ONE_YEAR_IN_SECONDS = 365 * 24 * 60 * 60; 9 | 10 | // 创建NextAuth选项 11 | export const buildAuthOptions = async (): Promise => { 12 | // 获取JWT密钥,如果环境变量不存在将使用临时密钥 13 | const secret = await getOrCreateJwtSecret(); 14 | 15 | return { 16 | providers: [ 17 | CredentialsProvider({ 18 | name: 'Credentials', 19 | credentials: { 20 | login: { label: "账户名或邮箱", type: "text" }, 21 | password: { label: "密码", type: "password" } 22 | }, 23 | async authorize(credentials, req) { 24 | if (!credentials?.login || !credentials?.password) { 25 | console.log("凭证不完整"); 26 | return null; 27 | } 28 | 29 | try { 30 | // 获取请求IP和UA信息 31 | const userAgent = req?.headers?.['user-agent'] || ''; 32 | const forwardedFor = req?.headers?.['x-forwarded-for'] as string || ''; 33 | const ip = forwardedFor ? forwardedFor.split(',')[0].trim() : ''; 34 | 35 | const user = await verifyPassword( 36 | credentials.login, 37 | credentials.password 38 | ); 39 | 40 | if (!user) { 41 | console.log("用户验证失败"); 42 | 43 | // 尝试查找用户ID以记录失败的登录尝试 44 | const userCheck = await prisma.user.findFirst({ 45 | where: { 46 | OR: [ 47 | { username: credentials.login }, 48 | { email: credentials.login } 49 | ] 50 | }, 51 | select: { id: true } 52 | }); 53 | 54 | if (userCheck) { 55 | // 记录登录失败 56 | await recordLoginAttempt({ 57 | userId: userCheck.id, 58 | ipAddress: ip, 59 | userAgent, 60 | success: false 61 | }); 62 | } 63 | 64 | return null; 65 | } 66 | 67 | // 记录登录成功 68 | await recordLoginAttempt({ 69 | userId: user.id, 70 | ipAddress: ip, 71 | userAgent, 72 | success: true 73 | }); 74 | 75 | return user; 76 | } catch (error) { 77 | console.error("验证过程中出错:", error); 78 | return null; 79 | } 80 | } 81 | }) 82 | ], 83 | callbacks: { 84 | async jwt({ token, user }) { 85 | 86 | if (user) { 87 | token.id = user.id; 88 | token.isAdmin = user.isAdmin; 89 | } 90 | return token; 91 | }, 92 | async session({ session, token }) { 93 | 94 | if (token && session.user) { 95 | session.user.id = token.id as string; 96 | session.user.isAdmin = token.isAdmin as boolean; 97 | } 98 | return session; 99 | }, 100 | }, 101 | session: { 102 | strategy: 'jwt', 103 | maxAge: ONE_YEAR_IN_SECONDS, // 一年有效期 104 | }, 105 | pages: { 106 | signIn: '/auth/login', 107 | newUser: '/auth/register', 108 | }, 109 | secret, 110 | debug: process.env.NODE_ENV !== 'production', 111 | jwt: { 112 | maxAge: ONE_YEAR_IN_SECONDS 113 | } 114 | }; 115 | }; 116 | 117 | // 创建NextAuth处理器 118 | export async function GET(req: Request, res: Response) { 119 | try { 120 | const authOptions = await buildAuthOptions(); 121 | return await NextAuth(authOptions)(req, res); 122 | } catch (error) { 123 | console.error("NextAuth GET错误:", error); 124 | throw error; 125 | } 126 | } 127 | 128 | export async function POST(req: Request, res: Response) { 129 | try { 130 | const authOptions = await buildAuthOptions(); 131 | return await NextAuth(authOptions)(req, res); 132 | } catch (error) { 133 | console.error("NextAuth POST错误:", error); 134 | throw error; 135 | } 136 | } -------------------------------------------------------------------------------- /src/app/api/status-pages/[id]/monitors/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { prisma } from '@/lib/prisma'; 3 | import { getServerSession } from 'next-auth'; 4 | import { authOptions } from '@/lib/auth'; 5 | 6 | // 获取状态页的监控项列表 7 | export async function GET( 8 | request: NextRequest, 9 | { params }: { params: Promise<{ id: string }> } 10 | ) { 11 | try { 12 | const { id } = await params; 13 | 14 | const session = await getServerSession(authOptions); 15 | if (!session?.user) { 16 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 17 | } 18 | 19 | const statusPage = await prisma.statusPage.findFirst({ 20 | where: { 21 | id, 22 | createdById: session.user.id 23 | }, 24 | include: { 25 | monitors: { 26 | include: { 27 | monitor: true 28 | }, 29 | orderBy: { 30 | order: 'asc' 31 | } 32 | } 33 | } 34 | }); 35 | 36 | if (!statusPage) { 37 | return NextResponse.json({ error: '状态页不存在' }, { status: 404 }); 38 | } 39 | 40 | return NextResponse.json(statusPage.monitors); 41 | } catch (error) { 42 | console.error('获取状态页监控项失败:', error); 43 | return NextResponse.json({ error: '获取状态页监控项失败' }, { status: 500 }); 44 | } 45 | } 46 | 47 | // 添加监控项到状态页 48 | export async function POST( 49 | request: NextRequest, 50 | { params }: { params: Promise<{ id: string }> } 51 | ) { 52 | try { 53 | const { id } = await params; 54 | 55 | const session = await getServerSession(authOptions); 56 | if (!session?.user) { 57 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 58 | } 59 | 60 | const body = await request.json(); 61 | const { monitorIds, displayNames, monitorId, displayName } = body; 62 | 63 | // 支持单个监控项和批量添加 64 | const monitorIdList = Array.isArray(monitorIds) ? monitorIds : (monitorId ? [monitorId] : []); 65 | const displayNameMap = displayNames || (displayName && monitorId ? { [monitorId]: displayName } : {}); 66 | 67 | if (monitorIdList.length === 0) { 68 | return NextResponse.json({ error: '缺少监控项ID' }, { status: 400 }); 69 | } 70 | 71 | // 检查状态页是否存在且属于当前用户 72 | const statusPage = await prisma.statusPage.findFirst({ 73 | where: { 74 | id, 75 | createdById: session.user.id 76 | } 77 | }); 78 | 79 | if (!statusPage) { 80 | return NextResponse.json({ error: '状态页不存在' }, { status: 404 }); 81 | } 82 | 83 | // 检查所有监控项是否存在 84 | const monitors = await prisma.monitor.findMany({ 85 | where: { 86 | id: { in: monitorIdList } 87 | } 88 | }); 89 | 90 | if (monitors.length !== monitorIdList.length) { 91 | return NextResponse.json({ error: '部分监控项不存在' }, { status: 404 }); 92 | } 93 | 94 | // 检查哪些监控项已经添加到状态页 95 | const existingMonitors = await prisma.statusPageMonitor.findMany({ 96 | where: { 97 | statusPageId: id, 98 | monitorId: { in: monitorIdList } 99 | } 100 | }); 101 | 102 | const existingMonitorIds = existingMonitors.map(em => em.monitorId); 103 | const newMonitorIds = monitorIdList.filter(id => !existingMonitorIds.includes(id)); 104 | 105 | if (newMonitorIds.length === 0) { 106 | return NextResponse.json({ error: '所有监控项已添加到状态页' }, { status: 400 }); 107 | } 108 | 109 | // 获取当前最大排序值 110 | const maxOrder = await prisma.statusPageMonitor.aggregate({ 111 | where: { statusPageId: id }, 112 | _max: { order: true } 113 | }); 114 | 115 | let currentOrder = (maxOrder._max.order || 0) + 1; 116 | 117 | // 批量创建状态页监控项 118 | const statusPageMonitors = await Promise.all( 119 | newMonitorIds.map(async (monitorId) => { 120 | return await prisma.statusPageMonitor.create({ 121 | data: { 122 | statusPageId: id, 123 | monitorId, 124 | displayName: displayNameMap[monitorId] || null, 125 | order: currentOrder++ 126 | }, 127 | include: { 128 | monitor: true 129 | } 130 | }); 131 | }) 132 | ); 133 | 134 | return NextResponse.json(statusPageMonitors, { status: 201 }); 135 | } catch (error) { 136 | console.error('添加监控项到状态页失败:', error); 137 | return NextResponse.json({ error: '添加监控项到状态页失败' }, { status: 500 }); 138 | } 139 | } -------------------------------------------------------------------------------- /src/app/auth/components/login-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import { signIn } from 'next-auth/react'; 5 | 6 | export default function LoginForm() { 7 | const [login, setLogin] = useState(''); 8 | const [password, setPassword] = useState(''); 9 | const [error, setError] = useState(''); 10 | const [loading, setLoading] = useState(false); 11 | 12 | // 注意:这里仅展示UI,实际锁定逻辑应由后端实现 13 | // 后端应记录用户登录失败次数和锁定状态 14 | 15 | const handleSubmit = async (e: React.FormEvent) => { 16 | e.preventDefault(); 17 | setLoading(true); 18 | setError(''); 19 | 20 | if (!login || !password) { 21 | setError('请填写所有字段'); 22 | setLoading(false); 23 | return; 24 | } 25 | 26 | try { 27 | 28 | // 实际应用中,应该在signIn前检查用户是否已被锁定 29 | // 例如: const lockStatus = await checkUserLockStatus(login); 30 | // if (lockStatus.isLocked) { setError(`账户已被锁定,请${lockStatus.remainingTime}后重试`); setLoading(false); return; } 31 | 32 | // 调用NextAuth的signIn方法进行登录 33 | const result = await signIn('credentials', { 34 | redirect: false, // 不自动重定向 35 | login, // 用户名或邮箱 36 | password, 37 | callbackUrl: '/dashboard' // 设置回调URL 38 | }); 39 | 40 | 41 | if (result?.error) { 42 | // 后端应在验证失败时增加失败计数 43 | // 例如: await incrementFailedAttempt(login); 44 | // 并在达到阈值时锁定账户 45 | 46 | setError('账号或密码不正确'); 47 | setLoading(false); 48 | return; 49 | } 50 | 51 | // 登录成功,后端应重置失败计数 52 | // 例如: await resetFailedAttempts(login); 53 | 54 | // 登录成功后,使用全页面导航而不是客户端路由 55 | // 这样可以确保下一个页面加载时会包含完整的会话状态 56 | console.log('登录成功,正在跳转到仪表盘...'); 57 | window.location.href = '/dashboard'; 58 | } catch (error) { 59 | console.error('登录错误', error); 60 | setError('登录过程中发生错误'); 61 | setLoading(false); 62 | } 63 | }; 64 | 65 | return ( 66 |
67 |
68 |

欢迎回来

69 |

登录到您的账户

70 | 71 | {error && ( 72 |
73 | {error} 74 |
75 | )} 76 | 77 |
78 | 81 | setLogin(e.target.value)} 86 | className="w-full p-3 text-white bg-dark-nav border border-purple-600/30 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-600/50 focus:border-purple-600" 87 | required 88 | /> 89 |
90 | 91 |
92 |
93 | 96 |
97 | setPassword(e.target.value)} 102 | className="w-full p-3 text-white bg-dark-nav border border-purple-600/30 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-600/50 focus:border-purple-600" 103 | required 104 | /> 105 |
106 | 107 | 120 | 121 |
122 |
123 | ); 124 | } -------------------------------------------------------------------------------- /src/app/api/monitor-groups/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { getServerSession } from 'next-auth'; 3 | import { authOptions } from '@/lib/auth'; 4 | import { prisma } from '@/lib/prisma'; 5 | 6 | // 获取单个分组 7 | export async function GET( 8 | request: NextRequest, 9 | { params }: { params: { id: string } } 10 | ) { 11 | try { 12 | const session = await getServerSession(authOptions); 13 | if (!session?.user?.id) { 14 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 15 | } 16 | 17 | const group = await prisma.monitorGroup.findFirst({ 18 | where: { 19 | id: params.id, 20 | createdById: session.user.id 21 | }, 22 | include: { 23 | monitors: { 24 | select: { 25 | id: true, 26 | name: true, 27 | lastStatus: true, 28 | active: true, 29 | type: true, 30 | lastCheckAt: true 31 | }, 32 | orderBy: { displayOrder: 'asc' } 33 | } 34 | } 35 | }); 36 | 37 | if (!group) { 38 | return NextResponse.json({ error: '分组不存在' }, { status: 404 }); 39 | } 40 | 41 | return NextResponse.json(group); 42 | } catch (error) { 43 | console.error('获取分组失败:', error); 44 | return NextResponse.json({ error: '获取分组失败' }, { status: 500 }); 45 | } 46 | } 47 | 48 | // 更新分组 49 | export async function PUT( 50 | request: NextRequest, 51 | { params }: { params: { id: string } } 52 | ) { 53 | try { 54 | const session = await getServerSession(authOptions); 55 | if (!session?.user?.id) { 56 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 57 | } 58 | 59 | const body = await request.json(); 60 | const { name, description, color } = body; 61 | 62 | if (!name || name.trim() === '') { 63 | return NextResponse.json({ error: '分组名称不能为空' }, { status: 400 }); 64 | } 65 | 66 | // 检查分组是否存在且属于当前用户 67 | const existingGroup = await prisma.monitorGroup.findFirst({ 68 | where: { 69 | id: params.id, 70 | createdById: session.user.id 71 | } 72 | }); 73 | 74 | if (!existingGroup) { 75 | return NextResponse.json({ error: '分组不存在' }, { status: 404 }); 76 | } 77 | 78 | // 检查新名称是否与其他分组冲突 79 | const nameConflict = await prisma.monitorGroup.findFirst({ 80 | where: { 81 | name: name.trim(), 82 | createdById: session.user.id, 83 | id: { not: params.id } 84 | } 85 | }); 86 | 87 | if (nameConflict) { 88 | return NextResponse.json({ error: '分组名称已存在' }, { status: 400 }); 89 | } 90 | 91 | const updatedGroup = await prisma.monitorGroup.update({ 92 | where: { id: params.id }, 93 | data: { 94 | name: name.trim(), 95 | description: description?.trim() || null, 96 | color: color || null 97 | } 98 | }); 99 | 100 | return NextResponse.json(updatedGroup); 101 | } catch (error) { 102 | console.error('更新分组失败:', error); 103 | return NextResponse.json({ error: '更新分组失败' }, { status: 500 }); 104 | } 105 | } 106 | 107 | // 删除分组 108 | export async function DELETE( 109 | request: NextRequest, 110 | { params }: { params: { id: string } } 111 | ) { 112 | try { 113 | const session = await getServerSession(authOptions); 114 | if (!session?.user?.id) { 115 | return NextResponse.json({ error: '未授权访问' }, { status: 401 }); 116 | } 117 | 118 | // 检查分组是否存在且属于当前用户 119 | const existingGroup = await prisma.monitorGroup.findFirst({ 120 | where: { 121 | id: params.id, 122 | createdById: session.user.id 123 | }, 124 | include: { 125 | monitors: true 126 | } 127 | }); 128 | 129 | if (!existingGroup) { 130 | return NextResponse.json({ error: '分组不存在' }, { status: 404 }); 131 | } 132 | 133 | // 如果分组中有监控项,先将它们移到未分组状态 134 | if (existingGroup.monitors.length > 0) { 135 | await prisma.monitor.updateMany({ 136 | where: { 137 | groupId: params.id 138 | }, 139 | data: { 140 | groupId: null 141 | } 142 | }); 143 | } 144 | 145 | // 删除分组 146 | await prisma.monitorGroup.delete({ 147 | where: { id: params.id } 148 | }); 149 | 150 | return NextResponse.json({ message: '分组删除成功' }); 151 | } catch (error) { 152 | console.error('删除分组失败:', error); 153 | return NextResponse.json({ error: '删除分组失败' }, { status: 500 }); 154 | } 155 | } -------------------------------------------------------------------------------- /src/app/dashboard/status-pages/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import { Header } from "@/components/header"; 5 | import { StatusPageList } from "./components/status-page-list"; 6 | import { StatusPageForm } from "./components/status-page-form"; 7 | 8 | interface StatusPage { 9 | id: string; 10 | name: string; 11 | slug: string; 12 | title: string; 13 | isPublic: boolean; 14 | createdAt: string; 15 | monitors: Array<{ 16 | monitor: { 17 | id: string; 18 | name: string; 19 | }; 20 | }>; 21 | } 22 | 23 | export default function StatusPagesPage() { 24 | const [statusPages, setStatusPages] = useState([]); 25 | const [isFormOpen, setIsFormOpen] = useState(false); 26 | const [loading, setLoading] = useState(true); 27 | const [editingStatusPage, setEditingStatusPage] = useState(null); 28 | 29 | // 获取状态页列表 30 | const fetchStatusPages = async () => { 31 | try { 32 | setLoading(true); 33 | const response = await fetch('/api/status-pages'); 34 | if (response.ok) { 35 | const data = await response.json(); 36 | setStatusPages(data); 37 | } 38 | } catch (error) { 39 | console.error('获取状态页列表失败:', error); 40 | } finally { 41 | setLoading(false); 42 | } 43 | }; 44 | 45 | useEffect(() => { 46 | fetchStatusPages(); 47 | }, []); 48 | 49 | const handleCreateSuccess = () => { 50 | setIsFormOpen(false); 51 | setEditingStatusPage(null); 52 | fetchStatusPages(); 53 | }; 54 | 55 | const handleEdit = (statusPage: StatusPage) => { 56 | setEditingStatusPage(statusPage); 57 | setIsFormOpen(true); 58 | }; 59 | 60 | const handleDelete = async (id: string) => { 61 | if (!confirm('确定要删除这个状态页吗?')) { 62 | return; 63 | } 64 | 65 | try { 66 | const response = await fetch(`/api/status-pages/${id}`, { 67 | method: 'DELETE' 68 | }); 69 | 70 | if (response.ok) { 71 | fetchStatusPages(); 72 | } else { 73 | const error = await response.json(); 74 | alert(`删除失败: ${error.error}`); 75 | } 76 | } catch (error) { 77 | console.error('删除状态页失败:', error); 78 | alert('删除状态页失败'); 79 | } 80 | }; 81 | 82 | 83 | 84 | return ( 85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | 96 | 97 | 返回监控面板 98 | 99 |
100 |

状态页管理

101 |

管理您的公开状态页面

102 |
103 |
104 | 111 |
112 | 113 | {loading ? ( 114 |
115 |
116 | 117 | 加载中... 118 |
119 |
120 | ) : ( 121 | 126 | )} 127 |
128 |
129 |
130 | 131 | {isFormOpen && ( 132 | { 135 | setIsFormOpen(false); 136 | setEditingStatusPage(null); 137 | }} 138 | onSuccess={handleCreateSuccess} 139 | /> 140 | )} 141 | 142 | 143 |
144 | ); 145 | } -------------------------------------------------------------------------------- /src/app/api/monitors/batch/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { monitorOperations } from '@/lib/db'; 3 | import { validateAuth } from '@/lib/auth-helpers'; 4 | 5 | /** 6 | * 批量操作监控项 7 | * 8 | * 请求体: 9 | * { 10 | * ids: string[], // 监控项ID数组 11 | * action: string, // 操作类型: 'start'|'stop'|'delete' 12 | * } 13 | */ 14 | export async function POST(request: Request) { 15 | try { 16 | // 验证用户是否已登录 17 | const authError = await validateAuth(); 18 | if (authError) return authError; 19 | 20 | const data = await request.json(); 21 | const { ids, action } = data; 22 | 23 | // 验证参数 24 | if (!ids || !Array.isArray(ids) || ids.length === 0) { 25 | return NextResponse.json( 26 | { error: '请提供有效的监控项ID列表' }, 27 | { status: 400 } 28 | ); 29 | } 30 | 31 | if (!action || !['start', 'stop', 'delete'].includes(action)) { 32 | return NextResponse.json( 33 | { error: '请提供有效的操作类型: start, stop 或 delete' }, 34 | { status: 400 } 35 | ); 36 | } 37 | 38 | // 处理不同的批量操作 39 | if (action === 'delete') { 40 | // 先停止监控 41 | if (ids.length > 0) { 42 | try { 43 | const { stopMonitor } = await import('@/lib/monitors/scheduler'); 44 | ids.forEach(id => { 45 | try { 46 | stopMonitor(id); 47 | } catch (err) { 48 | console.error(`停止监控 ${id} 失败:`, err); 49 | // 继续处理其他监控项 50 | } 51 | }); 52 | } catch (error) { 53 | console.error('导入停止监控函数失败:', error); 54 | } 55 | } 56 | 57 | // 删除监控项 58 | const results = await Promise.allSettled( 59 | ids.map(id => monitorOperations.deleteMonitor(id)) 60 | ); 61 | 62 | // 统计成功和失败的数量 63 | const succeeded = results.filter(r => r.status === 'fulfilled').length; 64 | const failed = results.length - succeeded; 65 | 66 | return NextResponse.json({ 67 | message: `成功删除 ${succeeded} 个监控项${failed > 0 ? `,${failed} 个删除失败` : ''}`, 68 | succeeded, 69 | failed 70 | }); 71 | } else { 72 | // 启动或停止监控 73 | const active = action === 'start'; 74 | 75 | // 更新监控项状态 76 | const updateResults = await Promise.allSettled( 77 | ids.map(id => monitorOperations.updateMonitor(id, { active })) 78 | ); 79 | 80 | // 异步处理监控调度,避免阻塞用户响应 81 | if (active) { 82 | // 异步启动监控 83 | setImmediate(async () => { 84 | try { 85 | const { scheduleMonitor } = await import('@/lib/monitors/scheduler'); 86 | 87 | // 启动监控 88 | const scheduleResults = await Promise.allSettled( 89 | ids.map(id => scheduleMonitor(id)) 90 | ); 91 | 92 | // 统计成功和失败的数量 93 | const succeeded = scheduleResults.filter(r => r.status === 'fulfilled').length; 94 | const failed = scheduleResults.length - succeeded; 95 | 96 | console.log(`批量启动监控完成: 成功 ${succeeded} 个,失败 ${failed} 个`); 97 | } catch (error) { 98 | console.error('批量启动监控失败:', error); 99 | } 100 | }); 101 | 102 | return NextResponse.json({ 103 | message: `正在启动 ${ids.length} 个监控项`, 104 | succeeded: ids.length, 105 | failed: 0 106 | }); 107 | } else { 108 | // 异步停止监控 109 | setImmediate(async () => { 110 | try { 111 | const { stopMonitor } = await import('@/lib/monitors/scheduler'); 112 | 113 | // 停止监控 114 | ids.forEach(id => { 115 | try { 116 | stopMonitor(id); 117 | } catch (err) { 118 | console.error(`停止监控 ${id} 失败:`, err); 119 | // 继续处理其他监控项 120 | } 121 | }); 122 | 123 | console.log(`批量停止监控完成: 处理了 ${ids.length} 个监控项`); 124 | } catch (error) { 125 | console.error('批量停止监控失败:', error); 126 | } 127 | }); 128 | 129 | // 统计成功和失败的数量 130 | const succeeded = updateResults.filter(r => r.status === 'fulfilled').length; 131 | const failed = updateResults.length - succeeded; 132 | 133 | return NextResponse.json({ 134 | message: `正在停止 ${ids.length} 个监控项`, 135 | succeeded, 136 | failed 137 | }); 138 | } 139 | } 140 | } catch (error) { 141 | console.error('批量操作监控项失败:', error); 142 | return NextResponse.json( 143 | { error: '批量操作失败,请稍后重试' }, 144 | { status: 500 } 145 | ); 146 | } 147 | } -------------------------------------------------------------------------------- /src/tests/monitors/checker-ports.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { checkPort } from '../../lib/monitors/checker-ports'; 3 | import { MONITOR_STATUS } from '../../lib/monitors/types'; 4 | 5 | // 创建模拟Socket对象 6 | const mockSocketObject = { 7 | setTimeout: vi.fn(), 8 | on: vi.fn(), 9 | connect: vi.fn(), 10 | destroy: vi.fn(), 11 | connectCallback: undefined as (() => void) | undefined, 12 | errorCallback: undefined as ((error: Error) => void) | undefined, 13 | timeoutCallback: undefined as (() => void) | undefined 14 | }; 15 | 16 | // 模拟net模块 17 | vi.mock('net', () => { 18 | return { 19 | default: { 20 | Socket: vi.fn(() => mockSocketObject) 21 | }, 22 | Socket: vi.fn(() => mockSocketObject) 23 | }; 24 | }); 25 | 26 | describe('端口监控检查器测试', () => { 27 | beforeEach(() => { 28 | vi.clearAllMocks(); 29 | 30 | // 重置回调 31 | mockSocketObject.connectCallback = undefined; 32 | mockSocketObject.errorCallback = undefined; 33 | mockSocketObject.timeoutCallback = undefined; 34 | 35 | // 模拟Socket方法 36 | mockSocketObject.on.mockImplementation((event, callback) => { 37 | if (event === 'connect') { 38 | mockSocketObject.connectCallback = callback; 39 | } else if (event === 'error') { 40 | mockSocketObject.errorCallback = callback; 41 | } else if (event === 'timeout') { 42 | mockSocketObject.timeoutCallback = callback; 43 | } 44 | return mockSocketObject; 45 | }); 46 | 47 | // 模拟Date.now以获得可预测的ping值 48 | vi.spyOn(Date, 'now') 49 | .mockReturnValueOnce(1000) // 开始时间 50 | .mockReturnValueOnce(1100); // 结束时间,差值为100ms 51 | }); 52 | 53 | afterEach(() => { 54 | vi.resetAllMocks(); 55 | }); 56 | 57 | it('应当在配置无效时返回DOWN状态', async () => { 58 | // @ts-expect-error 故意传入无效配置 59 | const result = await checkPort(null); 60 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 61 | expect(result.message).toContain('配置无效'); 62 | }); 63 | 64 | it('应当在缺少主机名时返回DOWN状态', async () => { 65 | const result = await checkPort({ hostname: '', port: 80 }); 66 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 67 | expect(result.message).toContain('缺少主机名'); 68 | }); 69 | 70 | it('应当在缺少端口号时返回DOWN状态', async () => { 71 | // @ts-expect-error 故意传入缺少端口的配置 72 | const result = await checkPort({ hostname: 'example.com' }); 73 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 74 | expect(result.message).toContain('缺少端口号'); 75 | }); 76 | 77 | it('应当在端口号无效时返回DOWN状态', async () => { 78 | const result = await checkPort({ hostname: 'example.com', port: -1 }); 79 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 80 | expect(result.message).toContain('不是有效的端口值'); 81 | }); 82 | 83 | it('应当在端口连接成功时返回UP状态', async () => { 84 | const portCheck = checkPort({ hostname: 'example.com', port: 80 }); 85 | 86 | // 模拟连接成功 87 | if (mockSocketObject.connectCallback) { 88 | mockSocketObject.connectCallback(); 89 | } 90 | 91 | const result = await portCheck; 92 | 93 | expect(mockSocketObject.setTimeout).toHaveBeenCalledWith(10000); 94 | expect(mockSocketObject.connect).toHaveBeenCalledWith(80, 'example.com'); 95 | expect(result.status).toBe(MONITOR_STATUS.UP); 96 | expect(result.message).toContain('端口 80 开放'); 97 | // 只要ping是数字即可,不需要检查大于0 98 | expect(typeof result.ping).toBe('number'); 99 | }); 100 | 101 | it('应当在端口连接超时时返回DOWN状态', async () => { 102 | const portCheck = checkPort({ hostname: 'example.com', port: 80 }); 103 | 104 | // 模拟连接超时 105 | if (mockSocketObject.timeoutCallback) { 106 | mockSocketObject.timeoutCallback(); 107 | } 108 | 109 | const result = await portCheck; 110 | 111 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 112 | expect(result.message).toContain('连接超时'); 113 | }); 114 | 115 | it('应当在端口连接错误时返回DOWN状态', async () => { 116 | const portCheck = checkPort({ hostname: 'example.com', port: 80 }); 117 | 118 | // 模拟连接错误 119 | if (mockSocketObject.errorCallback) { 120 | mockSocketObject.errorCallback(new Error('ECONNREFUSED')); 121 | } 122 | 123 | const result = await portCheck; 124 | 125 | expect(result.status).toBe(MONITOR_STATUS.DOWN); 126 | }); 127 | 128 | it('应当支持字符串形式的端口号', async () => { 129 | const portCheck = checkPort({ hostname: 'example.com', port: '80' }); 130 | 131 | // 模拟连接成功 132 | if (mockSocketObject.connectCallback) { 133 | mockSocketObject.connectCallback(); 134 | } 135 | 136 | const result = await portCheck; 137 | 138 | expect(mockSocketObject.connect).toHaveBeenCalledWith(80, 'example.com'); 139 | expect(result.status).toBe(MONITOR_STATUS.UP); 140 | }); 141 | }); -------------------------------------------------------------------------------- /src/app/dashboard/monitors/history/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import Link from "next/link"; 5 | import MonitorHistoryItem, { MonitorStatus } from "@/components/monitor-history-item"; 6 | 7 | // 定义历史记录接口 8 | interface HistoryItem { 9 | id: string; 10 | monitorId: string; 11 | monitorName: string; 12 | status: MonitorStatus; 13 | timestamp: string; 14 | message: string; 15 | duration: string; 16 | } 17 | 18 | export default function HistoryPage() { 19 | const [searchQuery, setSearchQuery] = useState(""); 20 | const [historyItems, setHistoryItems] = useState([]); 21 | const [isLoading, setIsLoading] = useState(true); 22 | 23 | // 获取历史数据 24 | useEffect(() => { 25 | const fetchHistoryData = async () => { 26 | try { 27 | setIsLoading(true); 28 | const response = await fetch('/api/monitors/history'); 29 | if (response.ok) { 30 | const data = await response.json(); 31 | setHistoryItems(data); 32 | } else { 33 | console.error('获取历史记录失败'); 34 | } 35 | } catch (error) { 36 | console.error('获取历史记录失败:', error); 37 | } finally { 38 | setIsLoading(false); 39 | } 40 | }; 41 | 42 | fetchHistoryData(); 43 | }, []); 44 | 45 | // 筛选历史记录 46 | const filteredItems = historyItems.filter(item => 47 | item.monitorName.toLowerCase().includes(searchQuery.toLowerCase()) || 48 | item.message.toLowerCase().includes(searchQuery.toLowerCase()) 49 | ); 50 | 51 | return ( 52 |
53 | {/* 顶部栏 */} 54 |
55 |

监控历史记录

56 |
57 | 58 | 返回仪表盘 59 | 60 |
61 |
62 | 63 | {/* 主内容区 */} 64 |
65 | {/* 搜索栏 */} 66 |
67 | setSearchQuery(e.target.value)} 73 | /> 74 | 75 |
76 | 77 | {/* 历史记录列表 */} 78 |
79 |
80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | {isLoading ? ( 92 | 93 | 98 | 99 | ) : filteredItems.length > 0 ? ( 100 | filteredItems.map((item) => ( 101 | 110 | )) 111 | ) : ( 112 | 113 | 118 | 119 | )} 120 | 121 |
状态监控项时间持续时间消息
94 |
95 | 加载中... 96 |
97 |
114 |
115 | 没有找到匹配的历史记录 116 |
117 |
122 |
123 |
124 |
125 |
126 | ); 127 | } -------------------------------------------------------------------------------- /src/app/dashboard/monitors/components/DatabaseOptionsSection.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from "react"; 2 | 3 | interface DatabaseOptionsSectionProps { 4 | monitorType: string; 5 | username: string; 6 | setUsername: Dispatch>; 7 | password: string; 8 | setPassword: Dispatch>; 9 | database: string; 10 | setDatabase: Dispatch>; 11 | query: string; 12 | setQuery: Dispatch>; 13 | } 14 | 15 | export function DatabaseOptionsSection({ 16 | monitorType, 17 | username, 18 | setUsername, 19 | password, 20 | setPassword, 21 | database, 22 | setDatabase, 23 | query, 24 | setQuery 25 | }: DatabaseOptionsSectionProps) { 26 | if (!["mysql", "redis"].includes(monitorType)) { 27 | return null; 28 | } 29 | 30 | return ( 31 |
32 |

33 | {monitorType === "redis" ? "Redis 连接选项" : "数据库连接选项"} 34 |

35 | 36 | {/* Redis 不需要用户名/数据库名 */} 37 | {monitorType !== "redis" && ( 38 |
39 |
40 | 41 | setUsername(e.target.value)} 45 | className="w-full px-4 py-2 rounded-lg dark:bg-dark-input bg-light-input border border-primary/20 focus:border-primary focus:outline-none" 46 | required 47 | /> 48 |
49 | 50 |
51 | 52 | setPassword(e.target.value)} 56 | className="w-full px-4 py-2 rounded-lg dark:bg-dark-input bg-light-input border border-primary/20 focus:border-primary focus:outline-none" 57 | /> 58 |
59 |
60 | )} 61 | 62 | {/* Redis 只需要密码 */} 63 | {monitorType === "redis" && ( 64 |
65 | 66 | setPassword(e.target.value)} 70 | className="w-full px-4 py-2 rounded-lg dark:bg-dark-input bg-light-input border border-primary/20 focus:border-primary focus:outline-none" 71 | /> 72 |

如果Redis不需要密码验证,请留空

73 |
74 | )} 75 | 76 | {/* 数据库名称 - 对于MySQL */} 77 | {monitorType === "mysql" && ( 78 |
79 | 80 | setDatabase(e.target.value)} 84 | className="w-full px-4 py-2 rounded-lg dark:bg-dark-input bg-light-input border border-primary/20 focus:border-primary focus:outline-none" 85 | /> 86 |

87 | 可选,默认值为 'mysql' 88 |

89 |
90 | )} 91 | 92 | {/* 查询 - 对于MySQL */} 93 | {monitorType === "mysql" && ( 94 |
95 | 96 | 102 |

103 | 用于测试数据库连接的查询语句。留空将使用默认的查询:SELECT 1; 104 |

105 |
106 | )} 107 | 108 | {/* Redis特有选项 */} 109 | {monitorType === "redis" && ( 110 |
111 |
112 | 113 | setQuery(e.target.value)} 118 | className="w-full px-4 py-2 rounded-lg dark:bg-dark-input bg-light-input border border-primary/20 focus:border-primary focus:outline-none" 119 | /> 120 |

121 | 用于监控 Redis 的命令。默认是 PING 122 |

123 |
124 |
125 | )} 126 |
127 | ); 128 | } -------------------------------------------------------------------------------- /src/lib/monitors/proxy-fetch.ts: -------------------------------------------------------------------------------- 1 | import { ProxyAgent, fetch as undiciFetch } from 'undici'; 2 | import { getAllProxySettings, SETTINGS_KEYS } from '../settings'; 3 | 4 | // 代理配置接口 5 | interface ProxyConfig { 6 | enabled: boolean; 7 | server: string; 8 | port: string; 9 | username?: string; 10 | password?: string; 11 | } 12 | 13 | // 创建代理配置 14 | async function getProxyConfig(): Promise { 15 | try { 16 | const proxySettings = await getAllProxySettings(); 17 | const enabled = proxySettings[SETTINGS_KEYS.PROXY_ENABLED] === 'true'; 18 | 19 | if (!enabled) { 20 | return null; 21 | } 22 | 23 | const server = proxySettings[SETTINGS_KEYS.PROXY_SERVER]; 24 | const port = proxySettings[SETTINGS_KEYS.PROXY_PORT]; 25 | 26 | if (!server || !port) { 27 | return null; 28 | } 29 | 30 | const username = proxySettings[SETTINGS_KEYS.PROXY_USERNAME]; 31 | const password = proxySettings[SETTINGS_KEYS.PROXY_PASSWORD]; 32 | 33 | return { 34 | enabled, 35 | server, 36 | port, 37 | username: username || undefined, 38 | password: password || undefined 39 | }; 40 | } catch { 41 | // 忽略错误 42 | return null; 43 | } 44 | } 45 | 46 | // 代理配置接口 47 | interface ProxyAgentConfig { 48 | uri: string; 49 | auth?: string; 50 | requestTls?: { 51 | rejectUnauthorized: boolean; 52 | }; 53 | } 54 | 55 | // 扩展的请求选项接口 56 | interface ExtendedRequestOptions extends RequestInit { 57 | dispatcher?: unknown; 58 | [key: string]: unknown; 59 | } 60 | 61 | /** 62 | * 使用系统配置的代理发送HTTP请求 63 | * 如果代理未启用或配置无效,将使用普通的fetch请求 64 | * @param url 请求URL 65 | * @param options 请求选项 66 | * @param ignoreTls 是否忽略TLS/SSL证书错误 67 | * @returns 返回fetch API的Response对象 68 | */ 69 | export async function proxyFetch( 70 | url: string, 71 | options?: RequestInit, 72 | ignoreTls = false 73 | ): Promise { 74 | const proxyConfig = await getProxyConfig(); 75 | 76 | if (!proxyConfig) { 77 | return await standardFetch(url, options, ignoreTls); 78 | } 79 | 80 | const proxyUrl = `http://${proxyConfig.server}:${proxyConfig.port}`; 81 | const auth = proxyConfig.username && proxyConfig.password 82 | ? `${proxyConfig.username}:${proxyConfig.password}` 83 | : undefined; 84 | 85 | // 创建代理配置 86 | const proxyAgentConfig: ProxyAgentConfig = { 87 | uri: proxyUrl 88 | }; 89 | 90 | if (auth) { 91 | proxyAgentConfig.auth = auth; 92 | } 93 | 94 | // 设置是否忽略证书错误 95 | if (ignoreTls) { 96 | proxyAgentConfig.requestTls = { 97 | rejectUnauthorized: false 98 | }; 99 | } 100 | 101 | const dispatcher = new ProxyAgent(proxyAgentConfig); 102 | 103 | try { 104 | // 使用传入的signal或创建默认的10秒超时信号 105 | let effectiveSignal = options?.signal; 106 | let timeoutId: NodeJS.Timeout | undefined; 107 | 108 | if (!effectiveSignal) { 109 | // 如果没有传入signal,创建默认的10秒超时 110 | const controller = new AbortController(); 111 | timeoutId = setTimeout(() => controller.abort(), 10000); 112 | effectiveSignal = controller.signal; 113 | } 114 | 115 | // 构建请求选项 116 | const fetchOptions: ExtendedRequestOptions = { 117 | ...(options || {}), 118 | dispatcher, 119 | signal: effectiveSignal 120 | }; 121 | 122 | const response = await undiciFetch(url, fetchOptions as any); 123 | if (timeoutId) { 124 | clearTimeout(timeoutId); 125 | } 126 | 127 | return response as unknown as globalThis.Response; 128 | } catch (error) { 129 | throw error; 130 | } 131 | } 132 | 133 | /** 134 | * 标准的fetch请求,不使用代理 135 | * 用于普通请求或测试 136 | * @param url 请求URL 137 | * @param options 请求选项 138 | * @param ignoreTls 是否忽略TLS/SSL证书错误 139 | */ 140 | export async function standardFetch( 141 | url: string, 142 | options?: RequestInit, 143 | ignoreTls = false 144 | ): Promise { 145 | try { 146 | // 使用传入的signal或创建默认的10秒超时信号 147 | let effectiveSignal = options?.signal; 148 | let timeoutId: NodeJS.Timeout | undefined; 149 | 150 | if (!effectiveSignal) { 151 | // 如果没有传入signal,创建默认的10秒超时 152 | const controller = new AbortController(); 153 | timeoutId = setTimeout(() => controller.abort(), 10000); 154 | effectiveSignal = controller.signal; 155 | } 156 | 157 | // 构建请求选项 158 | const fetchOptions: ExtendedRequestOptions = { 159 | ...(options || {}), 160 | signal: effectiveSignal 161 | }; 162 | 163 | // 如果设置忽略证书错误 164 | if (ignoreTls) { 165 | fetchOptions.dispatcher = { 166 | factory: (origin: string) => { 167 | return new ProxyAgent({ 168 | uri: origin, 169 | requestTls: { 170 | rejectUnauthorized: false 171 | } 172 | }); 173 | } 174 | }; 175 | } 176 | 177 | const response = await undiciFetch(url, fetchOptions as any); 178 | if (timeoutId) { 179 | clearTimeout(timeoutId); 180 | } 181 | 182 | return response as unknown as globalThis.Response; 183 | } catch (error) { 184 | throw error; 185 | } 186 | } -------------------------------------------------------------------------------- /src/app/dashboard/status-pages/components/status-page-list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | interface StatusPage { 4 | id: string; 5 | name: string; 6 | slug: string; 7 | title: string; 8 | isPublic: boolean; 9 | createdAt: string; 10 | monitors: Array<{ 11 | monitor: { 12 | id: string; 13 | name: string; 14 | }; 15 | }>; 16 | } 17 | 18 | interface StatusPageListProps { 19 | statusPages: StatusPage[]; 20 | onEdit: (statusPage: StatusPage) => void; 21 | onDelete: (id: string) => void; 22 | } 23 | 24 | export function StatusPageList({ statusPages, onEdit, onDelete }: StatusPageListProps) { 25 | const formatDate = (dateString: string) => { 26 | return new Date(dateString).toLocaleDateString('zh-CN', { 27 | year: 'numeric', 28 | month: 'short', 29 | day: 'numeric' 30 | }); 31 | }; 32 | 33 | const getStatusPageUrl = (slug: string) => { 34 | return `${window.location.origin}/status/${slug}`; 35 | }; 36 | 37 | if (statusPages.length === 0) { 38 | return ( 39 |
40 |
41 | 42 |
43 |

还没有状态页

44 |

创建您的第一个状态页来展示监控状态

45 |
46 | ); 47 | } 48 | 49 | return ( 50 |
51 | {statusPages.map((statusPage) => ( 52 |
56 |
57 |
58 |

59 | {statusPage.name} 60 |

61 |

{statusPage.title}

62 |
63 |
64 | 71 | 78 |
79 |
80 | 81 |
82 |
83 | URL标识符: 84 | 85 | {statusPage.slug} 86 | 87 |
88 | 89 |
90 | 监控项数量: 91 | {statusPage.monitors.length}个 92 |
93 | 94 |
95 | 访问权限: 96 | 101 | {statusPage.isPublic ? '公开' : '私有'} 102 | 103 |
104 | 105 |
106 | 创建时间: 107 | {formatDate(statusPage.createdAt)} 108 |
109 |
110 | 111 |
112 | 118 | 119 | 查看状态页 120 | 121 | 122 | 129 |
130 |
131 | ))} 132 |
133 | ); 134 | } --------------------------------------------------------------------------------