├── public ├── favicon.ico ├── apple-touch-icon.png └── logo.svg ├── postcss.config.js ├── src ├── main.js ├── style.css ├── utils │ ├── api.js │ ├── monitor.js │ └── chartConfig.js ├── App.vue └── components │ ├── Header.vue │ ├── Stats.vue │ ├── Footer.vue │ └── Card.vue ├── vite.config.js ├── .env ├── .gitignore ├── tailwind.config.js ├── functions └── api │ ├── README.md │ └── status.js ├── api ├── README.md └── status.js ├── package.json ├── LICENSE ├── index.html └── README.md /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLinMr/Uptime-Status/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLinMr/Uptime-Status/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import './style.css' 4 | 5 | const app = createApp(App) 6 | app.mount('#app') 7 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import path from 'path' 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | resolve: { 8 | alias: { 9 | '@': path.resolve(__dirname, './src') 10 | } 11 | }, 12 | server: { 13 | port: 3100, 14 | open: true 15 | } 16 | }) -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # UptimeRobot API Key 2 | VITE_UPTIMEROBOT_API_KEY = "ur2290572-af4663a4e3f83be26119abbe" 3 | 4 | # UptimeRobot API URL 5 | # 除腾讯云 EdgeOne Pages 、vercel 、cloudflare pages 外 6 | ## 其它部署方式需要自行搭建 API 代理 7 | ## 代理地址 https://api.uptimerobot.com/v2/getMonitors 8 | VITE_UPTIMEROBOT_API_URL = "/api/status" 9 | 10 | # 站点名称 11 | VITE_APP_TITLE = "梦爱吃鱼" 12 | 13 | # 监控面板排序方式 14 | # 支持 friendly_name 和 create_datetime 两种方式 15 | VITE_UPTIMEROBOT_STATUS_SORT = "friendly_name" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # 环境文件 27 | .env.local 28 | .env.*.local 29 | 30 | # 保留生产环境配置 31 | !.env.production 32 | 33 | package-lock.json 34 | pnpm-lock.yaml 35 | yarn.lock -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{vue,js,ts,jsx,tsx}", 6 | ], 7 | darkMode: 'class', 8 | theme: { 9 | extend: { 10 | animation: { 11 | 'fade': 'fade 0.3s ease-in-out', 12 | }, 13 | keyframes: { 14 | fade: { 15 | '0%': { 16 | opacity: '0', 17 | transform: 'translateY(10px)' 18 | }, 19 | '100%': { 20 | opacity: '1', 21 | transform: 'translateY(0)' 22 | }, 23 | } 24 | } 25 | }, 26 | }, 27 | plugins: [], 28 | } -------------------------------------------------------------------------------- /functions/api/README.md: -------------------------------------------------------------------------------- 1 | # API 目录说明 2 | 3 | 本目录 (`functions/api/`) 包含了 腾讯云 EdgeOne Pages 和 Cloudflare Pages 的 API 代理实现。 4 | 5 | ## 文件说明 6 | 7 | ### status.js 8 | 9 | 这是一个运行在边缘节点上的代理函数,主要用于: 10 | - 转发请求到 UptimeRobot API 11 | - 处理 CORS(跨域)请求 12 | - 处理错误响应 13 | 14 | ## 请求处理 15 | 16 | 1. OPTIONS 请求:返回 CORS 头部 17 | 2. POST 请求:转发到 UptimeRobot API 18 | 3. 错误处理:返回统一的错误响应 19 | 20 | ## 环境变量配置 21 | 22 | 在项目根目录的 `.env` 文件中配置: 23 | 24 | ```bash 25 | # API 代理地址 26 | VITE_UPTIMEROBOT_API_URL = "/api/status" # 使用默认配置 27 | ``` 28 | 29 | ## 部署说明 30 | 31 | - 支持多个平台部署: 32 | - 腾讯云 EdgeOne Pages 33 | - Cloudflare Pages 34 | - 路由 `/api/status` 由平台自动处理 35 | - 不需要额外的路径检查或路由配置 36 | 37 | 请确保在部署前正确配置环境变量,以保证 API 请求能够正常工作。 -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # API 目录说明 2 | 3 | 本目录 (`api/`) 包含了 Vercel Serverless Functions 的 API 代理实现。 4 | 5 | ## 文件说明 6 | 7 | ### status.js 8 | 9 | 这是一个运行在 Vercel 平台上的 Serverless Function,主要用于: 10 | - 转发请求到 UptimeRobot API 11 | - 处理 CORS(跨域)请求 12 | - 处理错误响应 13 | 14 | ## 请求处理 15 | 16 | 1. OPTIONS 请求:返回 CORS 头部 17 | 2. POST 请求:转发到 UptimeRobot API 18 | 3. 错误处理:返回统一的错误响应 19 | 20 | ## 环境变量配置 21 | 22 | 在项目根目录的 `.env` 文件中配置: 23 | 24 | ```bash 25 | # API 代理地址 26 | VITE_UPTIMEROBOT_API_URL = "/api/status" # 使用 Vercel 部署时 27 | ``` 28 | 29 | ## 部署说明 30 | 31 | - 此实现专门用于 Vercel 平台部署 32 | - 路由 `/api/status` 由 Vercel 自动处理 33 | - 不需要额外的路径检查或路由配置 34 | - Vercel 会自动将 `api` 目录下的文件识别为 Serverless Functions 35 | 36 | 请确保在部署前正确配置环境变量,以保证 API 请求能够正常工作。 -------------------------------------------------------------------------------- /api/status.js: -------------------------------------------------------------------------------- 1 | export default async function handler(req, res) { 2 | // 设置 CORS 头 3 | res.setHeader('Access-Control-Allow-Origin', '*') 4 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') 5 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type') 6 | 7 | // 处理 OPTIONS 请求 8 | if (req.method === 'OPTIONS') { 9 | return res.status(200).end() 10 | } 11 | 12 | // 只允许 POST 请求 13 | if (req.method !== 'POST') { 14 | return res.status(405).json({ error: '只支持 POST 请求' }) 15 | } 16 | 17 | try { 18 | const response = await fetch('https://api.uptimerobot.com/v2/getMonitors', { 19 | method: 'POST', 20 | headers: { 'Content-Type': 'application/json' }, 21 | body: JSON.stringify(req.body) 22 | }) 23 | 24 | const data = await response.json() 25 | return res.status(200).json(data) 26 | 27 | } catch (error) { 28 | return res.status(500).json({ error: '请求失败' }) 29 | } 30 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uptime-status", 3 | "version": "1.0.0", 4 | "private": true, 5 | "author": "JLinmr", 6 | "url": "https://www.bsgun.cn", 7 | "email": "ruoms@qq.com", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/JLinmr/Uptime-Status" 11 | }, 12 | "repositoryUrl": "https://github.com/JLinmr/Uptime-Status", 13 | "type": "module", 14 | "scripts": { 15 | "dev": "vite", 16 | "build": "vite build", 17 | "preview": "vite preview", 18 | "server": "node server/index.js" 19 | }, 20 | "dependencies": { 21 | "axios": "^1.6.7", 22 | "chart.js": "^4.4.7", 23 | "date-fns": "^3.6.0", 24 | "vue": "^3.4.15", 25 | "vue-chartjs": "^5.3.2", 26 | "vue-router": "^4.2.5" 27 | }, 28 | "devDependencies": { 29 | "@iconify/vue": "^4.3.0", 30 | "@vitejs/plugin-vue": "^5.0.3", 31 | "autoprefixer": "^10.4.20", 32 | "cors": "^2.8.5", 33 | "express": "^4.21.2", 34 | "postcss": "^8.5.1", 35 | "tailwindcss": "^3.4.17", 36 | "vite": "^6.0.5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 摸鱼玩家 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /functions/api/status.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API 代理实现 3 | * 这是一个边缘函数,运行在边缘节点上 4 | * 用于代理 UptimeRobot API 请求,避免跨域问题 5 | * 6 | * 支持以下部署平台: 7 | * - 腾讯云 EdgeOne Pages 8 | * - Cloudflare Pages 9 | * 10 | * 环境变量配置说明: 11 | * 在 .env 文件中设置 VITE_UPTIMEROBOT_API_URL: 12 | * - 使用默认配置:设置为 "/api/status" 13 | * - 其他部署方式:设置为你的完整代理地址 14 | */ 15 | 16 | export async function onRequest(context) { 17 | // 设置 CORS 头 18 | const corsHeaders = { 19 | 'Access-Control-Allow-Origin': '*', 20 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 21 | 'Access-Control-Allow-Headers': 'Content-Type' 22 | } 23 | 24 | // 处理 OPTIONS 请求 25 | if (context.request.method === 'OPTIONS') { 26 | return new Response(null, { headers: corsHeaders }) 27 | } 28 | 29 | try { 30 | // 从请求中获取数据 31 | const data = await context.request.json() 32 | 33 | // 转发请求到 UptimeRobot API 34 | const response = await fetch('https://api.uptimerobot.com/v2/getMonitors', { 35 | method: 'POST', 36 | headers: { 'Content-Type': 'application/json' }, 37 | body: JSON.stringify(data) 38 | }) 39 | 40 | const newResponse = new Response(response.body, response) 41 | newResponse.headers.set('Access-Control-Allow-Origin', '*') 42 | return newResponse 43 | 44 | } catch (error) { 45 | return new Response('请求失败', { status: 500 }) 46 | } 47 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %VITE_APP_TITLE% - 站点监测 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 26 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @keyframes border-spin { 6 | 0% { 7 | clip-path: inset(0 100% 100% 0); 8 | opacity: 0; 9 | } 10 | 50% { 11 | clip-path: inset(0 0 100% 0); 12 | opacity: 1; 13 | } 14 | 100% { 15 | clip-path: inset(0 0 0 0); 16 | opacity: 1; 17 | } 18 | } 19 | 20 | @keyframes border-unspin { 21 | 0% { 22 | clip-path: inset(0 0 0 0); 23 | opacity: 1; 24 | } 25 | 50% { 26 | clip-path: inset(100% 0 0 0); 27 | opacity: 1; 28 | } 29 | 100% { 30 | clip-path: inset(100% 0 0 100%); 31 | opacity: 0; 32 | } 33 | } 34 | 35 | /* 隐藏滚动条 */ 36 | ::-webkit-scrollbar { 37 | display: none; 38 | } 39 | 40 | * { 41 | font-family: HarmonyOS_Regular,MiSans,HarmonyOS Sans SC,sans-serif; /* 字体 */ 42 | -ms-overflow-style: none; /* IE and Edge */ 43 | scrollbar-width: none; /* Firefox */ 44 | } 45 | 46 | /* 添加通用卡片样式 */ 47 | @layer components { 48 | /* 基础卡片样式 */ 49 | .card-base { 50 | @apply p-4 rounded-xl bg-white dark:bg-gray-800/50 51 | border border-gray-200/60 dark:border-gray-700/60 52 | transition-all duration-300; 53 | } 54 | 55 | /* 统一的边框动画样式 */ 56 | .animated-border { 57 | @apply relative; 58 | } 59 | 60 | .animated-border::after { 61 | @apply absolute inset-0 rounded-xl border-2 opacity-0 pointer-events-none; 62 | content: ''; 63 | } 64 | 65 | /* 只在悬浮时显示进入动画 */ 66 | .animated-border:hover::after { 67 | animation: border-spin 0.5s ease-out forwards; 68 | } 69 | 70 | /* 只在悬浮后的元素显示退出动画 */ 71 | .animated-border.hovered:not(:hover)::after { 72 | animation: border-unspin 0.5s ease-out forwards; 73 | } 74 | 75 | .inner-card { 76 | @apply bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 77 | border border-gray-100/60 dark:border-gray-700/60; 78 | } 79 | } 80 | 81 | #app { 82 | width: 100%; 83 | margin: 0 auto; 84 | } -------------------------------------------------------------------------------- /src/utils/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API 请求相关工具函数 3 | * 主要用于处理与 UptimeRobot API 的通信 4 | */ 5 | 6 | import axios from 'axios' 7 | import { processMonitorData, generateTimeRanges } from './monitor' 8 | 9 | /** API 配置常量 */ 10 | const API_URL = import.meta.env.VITE_UPTIMEROBOT_API_URL 11 | const API_KEY = import.meta.env.VITE_UPTIMEROBOT_API_KEY 12 | 13 | /* 面板排序方式 */ 14 | const STATUS_SORT = import.meta.env.VITE_UPTIMEROBOT_STATUS_SORT 15 | 16 | /** 17 | * 获取监控数据 18 | * @async 19 | * @returns {Promise} 处理后的监控数据数组 20 | * @throws {Error} 当 API 请求失败时抛出错误 21 | */ 22 | export const fetchMonitorData = async () => { 23 | const controller = new AbortController() 24 | const timeoutId = setTimeout(() => controller.abort(), 30000) 25 | 26 | try { 27 | const response = await axios.post( 28 | API_URL, 29 | { 30 | api_key: API_KEY, 31 | format: 'json', 32 | response_times: 1, 33 | logs: 1, 34 | custom_uptime_ranges: generateTimeRanges(), 35 | response_times_start_date: Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000), 36 | response_times_end_date: Math.floor(Date.now() / 1000) 37 | }, 38 | { 39 | signal: controller.signal, 40 | timeout: 30000 41 | } 42 | ) 43 | 44 | if (response.data?.stat !== 'ok') { 45 | throw new Error('API 请求失败: ' + response.data?.message || '未知错误') 46 | } 47 | 48 | if (STATUS_SORT === 'friendly_name') { 49 | return response.data.monitors 50 | .sort((a, b) => b.friendly_name - a.friendly_name) 51 | .map(processMonitorData) 52 | } else if (STATUS_SORT === 'create_datetime') { 53 | return response.data.monitors 54 | .sort((a, b) => b.create_datetime - a.create_datetime) 55 | .map(processMonitorData) 56 | } 57 | 58 | } catch (error) { 59 | console.error('获取监控数据失败:', error) 60 | throw new Error('获取监控数据失败: ' + error.message) 61 | } finally { 62 | clearTimeout(timeoutId) 63 | } 64 | } -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | -------------------------------------------------------------------------------- /src/components/Stats.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | -------------------------------------------------------------------------------- /src/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Status Monitor Logo 3 |

4 | 5 |

站点监测

6 | 7 |

优雅的站点状态监控面板

8 | 9 |

10 | Vue 11 | Tailwind 12 | License 13 |

14 | 15 |

16 | 17 | Deploy with Vercel 18 | 19 | 20 | Deploy with EdgeOne Pages 21 | 22 | 23 | 部署到腾讯云 EdgeOne 24 | 25 | 26 | 部署到 Cloudflare Pages 27 | 28 |

29 | 30 |

🎮 在线演示: 31 | 32 | https://status.bsgun.cn 33 | 34 |

35 | 36 | ## 📖 简介 37 | 38 | 站点监测是一个基于 UptimeRobot API 开发的站点状态监控面板,支持多站点状态监控、实时通知、故障统计等功能。界面简洁美观,响应式设计,支持亮暗主题切换。 39 | 40 | ## ✨ 功能预览 41 | 42 | ![功能预览](https://i1.wp.com/dev.ruom.top/i/2025/01/25/629114.webp) 43 | 44 | ## ✨ 特性 45 | 46 | - 📊 实时监控:支持多种监控方式 47 | - 📱 响应式设计:适配移动端和桌面端 48 | - 🌓 主题切换:支持亮色/暗色主题 49 | - 📈 数据统计:可视化展示可用率和响应时间 50 | - 🔔 故障记录:详细的宕机记录和原因分析 51 | - 🔄 自动刷新:定时自动更新监控数据 52 | - 💫 平滑动画:流畅的用户界面交互体验 53 | 54 | ## ⚙️ 部署配置 55 | 56 | ### 环境要求 57 | 58 | - Node.js >= 16.16.0 59 | - NPM >= 8.15.0 或 PNPM >= 8.0.0 60 | 61 | ### 获取 UptimeRobot API Key 62 | 63 | 1. 注册/登录 [UptimeRobot](https://uptimerobot.com/) 64 | 2. 进入 [Integrations & API](https://dashboard.uptimerobot.com/integrations) 65 | 3. 下拉到最底部在 Main API keys 部分创建 **Read-Only API Key** 66 | 4. 复制生成的 API Key 67 | 68 | ### API 代理说明 69 | 70 | 本项目支持以下三种部署方式,均可实现自动处理跨域请求: 71 | 72 | 1. **腾讯云 EdgeOne Pages** 73 | - 点击上方蓝色 "Deploy" 按钮 74 | - 连接到GitHub,选择项目 75 | - 框架预设选择Vue,点击开始部署 76 | - 使用默认配置 `VITE_UPTIMEROBOT_API_URL = "/api/status"` 77 | 78 | 2. **Vercel** 79 | - 点击上方黑色 "Deploy" 按钮 80 | - 连接到GitHub,选择项目 81 | - 填写项目名称,点击Create 82 | - 使用默认配置 `VITE_UPTIMEROBOT_API_URL = "/api/status"` 83 | 84 | 3. **Cloudflare Pages** 85 | - 点击上方橙色 "Deploy" 按钮 86 | - 找到计算(worker) 部分 87 | - 点击创建,选择Pages,连接到GitHub,选择项目,点击开始创建 88 | - 框架预设选择Vue,点击保持并部署 89 | - 使用默认配置 `VITE_UPTIMEROBOT_API_URL = "/api/status"` 90 | 91 | 4. **其他平台** 92 | - 自行搭建 API 代理 93 | - 在 `.env` 文件中设置 `VITE_UPTIMEROBOT_API_URL` 为你的 API 代理地址 94 | ### 快速开始 95 | 96 | 1. 克隆项目 97 | ```bash 98 | git clone https://github.com/JLinmr/uptime-status.git 99 | cd uptime-status 100 | ``` 101 | 102 | 2. 安装依赖 103 | ```bash 104 | pnpm install 105 | # 或 106 | npm install 107 | ``` 108 | 109 | 3. 配置环境变量 110 | 111 | 在 `.env` 文件中修改以下配置: 112 | ```bash 113 | # UptimeRobot API Key 114 | VITE_UPTIMEROBOT_API_KEY = "ur2290572-af4663a4e3f83be26119abbe" 115 | 116 | # UptimeRobot API URL 117 | # 除腾讯云 EdgeOne Pages 、vercel 、cloudflare pages 外 118 | ## 其它部署方式需要自行搭建 API 代理 119 | ## 代理地址 https://api.uptimerobot.com/v2/getMonitors 120 | VITE_UPTIMEROBOT_API_URL = "/api/status" 121 | 122 | # 站点名称 123 | VITE_APP_TITLE = "梦爱吃鱼" 124 | 125 | # 监控面板排序方式 126 | # 支持 friendly_name 和 create_datetime 两种方式 127 | VITE_UPTIMEROBOT_STATUS_SORT = "friendly_name" 128 | ``` 129 | 130 | 4. 开发调试 131 | ```bash 132 | pnpm dev 133 | # 或 134 | npm run dev 135 | 136 | # 开发环境需要将 VITE_UPTIMEROBOT_API_URL 设置为 "https://api.uptimerobot.com/v2/getMonitors" 137 | ``` 138 | 139 | 5. 构建部署 140 | ```bash 141 | pnpm build 142 | # 或 143 | npm run build 144 | ``` 145 | 构建的文件在 `dist` 目录下,将 `dist` 目录部署到服务器即可。 146 | 147 | ## CDN赞助 148 | 149 | 本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助:EdgeOne 提供长期有效的免费套餐,包含不限量的流量和请求,覆盖中国大陆节点,且无任何超额收费,感兴趣的朋友可以去 EdgeOne 官网获取 150 | 151 | 最佳亚洲 CDN、Edge 和安全解决方案 - 腾讯 EdgeOne 152 | 153 | 154 | 155 | ## 📝 开源协议 156 | 157 | 本项目基于 [MIT License](LICENSE) 开源,使用时请遵守开源协议。 158 | 159 | ## 🙏 致谢 160 | 161 | - [UptimeRobot](https://uptimerobot.com/) - 提供监控 API 支持 162 | - [Vue.js](https://vuejs.org/) - 前端框架 163 | - [Tailwind CSS](https://tailwindcss.com/) - CSS 框架 164 | - [Chart.js](https://www.chartjs.org/) - 图表库 165 | -------------------------------------------------------------------------------- /src/utils/monitor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 监控数据处理工具模块 3 | * @module monitor 4 | */ 5 | 6 | /** 7 | * 监控数据处理相关常量 8 | * @constant {Object} 9 | * @property {number} THIRTY_DAYS - 30天的天数 10 | * @property {number} MS_PER_DAY - 每天的毫秒数 11 | * @property {number} DOWNTIME_TYPE - 停机类型标识 12 | * @property {number} HOURS_IN_DAY - 每天的小时数 13 | */ 14 | const CONSTANTS = { 15 | THIRTY_DAYS: 30, 16 | MS_PER_DAY: 24 * 60 * 60 * 1000, 17 | DOWNTIME_TYPE: 1, 18 | HOURS_IN_DAY: 24 19 | } 20 | 21 | /** 22 | * 数据验证工具类 23 | * @class 24 | */ 25 | class Validator { 26 | /** 27 | * 验证数值是否有效 28 | * @static 29 | * @param {*} value - 要验证的值 30 | * @returns {boolean} 是否为有效数值 31 | */ 32 | static isValidNumber(value) { 33 | return value != null && !isNaN(value) && value > 0 34 | } 35 | 36 | /** 37 | * 验证数组是否有效 38 | * @static 39 | * @param {Array} arr - 要验证的数组 40 | * @returns {boolean} 是否为有效数组 41 | */ 42 | static isValidArray(arr) { 43 | return Array.isArray(arr) && arr.length > 0 44 | } 45 | 46 | /** 47 | * 验证时间戳是否有效 48 | * @static 49 | * @param {number} timestamp - 要验证的时间戳 50 | * @returns {boolean} 是否为有效时间戳 51 | */ 52 | static isValidTimestamp(timestamp) { 53 | return this.isValidNumber(timestamp) && timestamp > 0 54 | } 55 | } 56 | 57 | /** 58 | * 时间处理工具类 59 | * @class 60 | */ 61 | class TimeUtils { 62 | /** 63 | * 获取30天前的时间戳 64 | * @static 65 | * @returns {number} UNIX时间戳 66 | */ 67 | static getThirtyDaysAgo() { 68 | return Math.floor((Date.now() - CONSTANTS.THIRTY_DAYS * CONSTANTS.MS_PER_DAY) / 1000) 69 | } 70 | 71 | /** 72 | * 生成时间范围字符串 73 | * @static 74 | * @returns {string} 格式化的时间范围字符串 75 | */ 76 | static generateTimeRanges() { 77 | return Array.from({ length: CONSTANTS.THIRTY_DAYS }, (_, i) => { 78 | const date = new Date() 79 | date.setDate(date.getDate() - i) 80 | const start = new Date(date).setHours(0, 0, 0, 0) 81 | const end = new Date(date).setHours(23, 59, 59, 999) 82 | return `${Math.floor(start / 1000)}_${Math.floor(end / 1000)}` 83 | }).join('-') 84 | } 85 | } 86 | 87 | /** 88 | * 监控数据处理类 89 | * @class 90 | */ 91 | class MonitorDataProcessor { 92 | /** 93 | * 计算平均响应时间 94 | * @static 95 | * @param {Object} monitor - 监控数据对象 96 | * @returns {number|null} 平均响应时间或null 97 | */ 98 | static calculateAvgResponseTime(monitor) { 99 | try { 100 | if (Validator.isValidArray(monitor.response_times)) { 101 | // 获取24小时前的时间戳 102 | const twentyFourHoursAgo = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000) 103 | 104 | // 只计算最近24小时的响应时间 105 | const validTimes = monitor.response_times.filter(time => 106 | time && 107 | Validator.isValidNumber(time.value) && 108 | time.datetime >= twentyFourHoursAgo 109 | ) 110 | 111 | return validTimes.length > 0 112 | ? Math.round(validTimes.reduce((sum, time) => sum + time.value, 0) / validTimes.length) 113 | : null 114 | } 115 | 116 | // 如果没有详细的响应时间数据,则使用 average_response_time 117 | return Validator.isValidNumber(monitor.average_response_time) 118 | ? Math.round(monitor.average_response_time) 119 | : null 120 | } catch (error) { 121 | console.error('计算平均响应时间出错:', error) 122 | return null 123 | } 124 | } 125 | 126 | static processDowntimeLogs(logs = []) { 127 | const thirtyDaysAgo = TimeUtils.getThirtyDaysAgo() 128 | const recentLogs = logs.filter(log => 129 | log.type === CONSTANTS.DOWNTIME_TYPE && log.datetime >= thirtyDaysAgo 130 | ) 131 | 132 | return { 133 | logs: recentLogs.sort((a, b) => b.datetime - a.datetime), 134 | totalDowntime: recentLogs.reduce((total, log) => total + (log.duration || 0), 0) 135 | } 136 | } 137 | 138 | static processUptimeData(uptimeRanges) { 139 | const dailyUptimes = uptimeRanges?.split('-').map(Number).reverse() || [] 140 | const validUptimes = dailyUptimes.filter(Validator.isValidNumber) 141 | 142 | return { 143 | dailyUptimes, 144 | uptime: validUptimes.length > 0 145 | ? validUptimes.reduce((sum, value) => sum + value, 0) / validUptimes.length 146 | : 0 147 | } 148 | } 149 | 150 | static processDailyResponseTimes(monitor) { 151 | try { 152 | // 创建24个小时的数组 153 | const hourlyResponseTimes = Array(CONSTANTS.HOURS_IN_DAY).fill(null) 154 | 155 | if (!Validator.isValidArray(monitor.response_times)) { 156 | return hourlyResponseTimes 157 | } 158 | 159 | // 按小时分组响应时间 160 | const hourlyGroups = monitor.response_times.reduce((groups, time) => { 161 | if (!time || !Validator.isValidNumber(time.value)) return groups 162 | 163 | const date = new Date(time.datetime * 1000) 164 | const hourIndex = Math.floor((Date.now() - date.getTime()) / (60 * 60 * 1000)) 165 | 166 | if (hourIndex >= 0 && hourIndex < CONSTANTS.HOURS_IN_DAY) { 167 | if (!groups[hourIndex]) groups[hourIndex] = [] 168 | groups[hourIndex].push(time.value) 169 | } 170 | 171 | return groups 172 | }, {}) 173 | 174 | // 计算每小时的平均响应时间 175 | Object.entries(hourlyGroups).forEach(([hourIndex, times]) => { 176 | if (times.length > 0) { 177 | hourlyResponseTimes[hourIndex] = Math.round( 178 | times.reduce((sum, value) => sum + value, 0) / times.length 179 | ) 180 | } 181 | }) 182 | 183 | return hourlyResponseTimes 184 | } catch (error) { 185 | console.error('处理响应时间数据出错:', error) 186 | return Array(CONSTANTS.HOURS_IN_DAY).fill(null) 187 | } 188 | } 189 | } 190 | 191 | /** 192 | * 处理监控数据 193 | * @param {Object} monitor - 原始监控数据 194 | * @returns {Object} 处理后的监控数据 195 | * @throws {Error} 处理失败时抛出错误 196 | */ 197 | export const processMonitorData = (monitor) => { 198 | try { 199 | const avgResponseTime = MonitorDataProcessor.calculateAvgResponseTime(monitor) 200 | const dailyResponseTimes = MonitorDataProcessor.processDailyResponseTimes(monitor) 201 | const { logs: downtimeLogs, totalDowntime } = MonitorDataProcessor.processDowntimeLogs( 202 | monitor.logs 203 | ) 204 | const { dailyUptimes, uptime } = MonitorDataProcessor.processUptimeData( 205 | monitor.custom_uptime_ranges 206 | ) 207 | 208 | return { 209 | ...monitor, 210 | stats: { 211 | avgResponseTime, 212 | dailyResponseTimes, 213 | uptime, 214 | dailyUptimes, 215 | downtimeLogs, 216 | totalDowntime 217 | } 218 | } 219 | } catch (error) { 220 | console.error('处理监控数据失败:', error) 221 | throw new Error('处理监控数据失败: ' + error.message) 222 | } 223 | } 224 | 225 | /** 导出工具函数 */ 226 | export const generateTimeRanges = TimeUtils.generateTimeRanges -------------------------------------------------------------------------------- /src/utils/chartConfig.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 图表配置工具模块 3 | * @module chartConfig 4 | */ 5 | 6 | import { format } from 'date-fns' 7 | 8 | /** 9 | * 图表基础配置对象 10 | * @constant {Object} 11 | * @property {Object} styles - 图表样式配置 12 | * @property {Object} points - 数据点样式配置 13 | * @property {Object} colors - 图表颜色配置 14 | */ 15 | const CHART_CONFIG = { 16 | styles: { 17 | responsive: true, 18 | maintainAspectRatio: false, 19 | plugins: { 20 | legend: { display: false }, 21 | }, 22 | interaction: { 23 | intersect: false, 24 | mode: 'nearest' 25 | } 26 | }, 27 | 28 | points: { 29 | pointBackgroundColor: '#fff', 30 | pointBorderColor: '#10b981', 31 | pointBorderWidth: 1.5, 32 | pointHoverBorderWidth: 1.5, 33 | pointHoverBackgroundColor: '#fff' 34 | }, 35 | 36 | colors: { 37 | success: '#10b981', 38 | warning: '#eab308', 39 | error: '#ef4444', 40 | orange: '#ff7a45', 41 | gray: 'rgb(120, 120, 120, 0.3)', 42 | paused: '#eab308' 43 | } 44 | } 45 | 46 | /** 47 | * 根据可用率值获取对应的图表颜色 48 | * @param {number} value - 可用率值 49 | * @param {boolean} isBeforeCreation - 是否为创建前的数据 50 | * @param {number} status - 监控状态 (2: 在线, 0: 暂停, 9: 离线) 51 | * @returns {string} 颜色代码 52 | */ 53 | export const getChartColor = (value, isBeforeCreation, status) => { 54 | if (isBeforeCreation) return CHART_CONFIG.colors.gray 55 | if (status === 0) return CHART_CONFIG.colors.paused // 使用 status 判断暂停状态 56 | if (value === null || isNaN(value)) return CHART_CONFIG.colors.error 57 | 58 | const thresholds = [ 59 | { min: 99.9, color: CHART_CONFIG.colors.success }, // ≥99.9% 绿色 60 | { min: 90, color: CHART_CONFIG.colors.warning }, // ≥90% 黄色 61 | { min: 0.1, color: CHART_CONFIG.colors.orange } // >0% 橙色 62 | ] 63 | 64 | // 等于0时显示红色,其他情况按阈值判断 65 | if (value === 0) return CHART_CONFIG.colors.error 66 | return thresholds.find(t => value >= t.min)?.color || CHART_CONFIG.colors.orange 67 | } 68 | 69 | /** 70 | * 获取状态时间线图表配置 71 | * @param {Object} monitor - 监控数据对象 72 | * @param {Object} dateRange - 日期范围对象 73 | * @param {boolean} isMobile - 是否为移动设备 74 | * @returns {Object} 图表配置对象 75 | */ 76 | export const getStatusChartConfig = (monitor, dateRange, isMobile) => { 77 | const data = monitor.stats?.dailyUptimes ?? Array(30).fill(null) 78 | 79 | // 添加时间验证逻辑 80 | const createTime = monitor.create_datetime * 1000 81 | const now = Date.now() 82 | const effectiveCreateTime = createTime > now ? now : createTime 83 | 84 | const daysSinceStart = Math.max(0, Math.floor( 85 | (new Date(effectiveCreateTime) - dateRange.startDate) / 86400000 86 | )) 87 | 88 | const pointSize = isMobile ? { radius: 4, hoverRadius: 5 } : { radius: 8, hoverRadius: 10 } 89 | 90 | return { 91 | data: { 92 | datasets: [{ 93 | data: data.map((value, i) => ({ 94 | x: i, 95 | y: 50, 96 | value: i < daysSinceStart ? null : (value ?? null), 97 | date: dateRange.dates[i], 98 | status: monitor.status, 99 | isBeforeCreation: i < daysSinceStart // 添加创建前标记 100 | })), 101 | backgroundColor: data.map((v, i) => 102 | getChartColor(v, i < daysSinceStart, monitor.status) 103 | ), 104 | borderColor: 'transparent', 105 | ...pointSize, 106 | pointStyle: 'rectRounded' 107 | }] 108 | }, 109 | options: { 110 | ...CHART_CONFIG.styles, 111 | plugins: { 112 | ...CHART_CONFIG.styles.plugins, 113 | tooltip: { 114 | callbacks: { 115 | title: items => format(items[0].raw.date, 'yyyy-MM-dd'), 116 | label: context => { 117 | if (context.raw.isBeforeCreation) { 118 | return '无数据' // 创建前显示"无数据" 119 | } 120 | if (context.raw.status === 0) { 121 | return '已暂停' // 然后再判断暂停状态 122 | } 123 | if (context.raw.value === null) { 124 | return '无数据' 125 | } 126 | return `可用率:${context.raw.value.toFixed(2)}%` 127 | } 128 | } 129 | } 130 | }, 131 | scales: { 132 | x: { display: false, min: -0.5, max: 29.5 }, 133 | y: { display: false, min: 45, max: 55 } 134 | }, 135 | animation: { duration: 200 }, 136 | elements: { 137 | point: { 138 | ...pointSize, 139 | borderWidth: 0, 140 | borderRadius: 4 141 | } 142 | } 143 | } 144 | } 145 | } 146 | 147 | /** 148 | * 获取响应时间图表数据 149 | * @param {Object} monitor - 监控数据对象 150 | * @returns {Object} 图表数据配置对象 151 | */ 152 | export const getResponseTimeChartData = (monitor) => { 153 | const hourLabels = Array.from({ length: 24 }, (_, i) => { 154 | const date = new Date() 155 | date.setHours(date.getHours() - i) 156 | return format(date, 'HH:mm') 157 | }).reverse() 158 | 159 | // 处理数据 160 | const validData = [...(monitor?.stats?.dailyResponseTimes || [])] 161 | .reverse() 162 | .slice(-24) // 只取最近24小时的数据 163 | .map(time => { 164 | // 严格的数值检查 165 | if (typeof time !== 'number' || isNaN(time) || time < 0 || time > 60000) { 166 | return null 167 | } 168 | return time 169 | }) 170 | 171 | return { 172 | labels: hourLabels, 173 | datasets: [{ 174 | label: '响应时间', 175 | data: validData, 176 | borderColor: '#10b981', 177 | backgroundColor: 'rgba(16, 185, 129, 0.05)', 178 | borderWidth: 1.5, 179 | fill: true, 180 | tension: 0.4, 181 | pointRadius: 0, 182 | pointHoverRadius: 4, 183 | ...CHART_CONFIG.points 184 | }] 185 | } 186 | } 187 | 188 | /** 189 | * 响应时间图表配置对象 190 | * @constant {Object} 191 | */ 192 | export const responseTimeChartOptions = { 193 | ...CHART_CONFIG.styles, 194 | plugins: { 195 | ...CHART_CONFIG.styles.plugins, 196 | tooltip: { 197 | enabled: true, 198 | mode: 'index', 199 | intersect: false, 200 | callbacks: { 201 | title: items => items[0]?.label || '', 202 | label: context => context.raw ? `响应时间:${context.raw} ms` : '无数据' 203 | } 204 | } 205 | }, 206 | scales: { 207 | x: { 208 | grid: { display: false, drawBorder: false }, 209 | ticks: { 210 | maxRotation: 0, 211 | color: 'rgb(156, 163, 175)', 212 | padding: 8, 213 | maxTicksLimit: 12, 214 | font: { size: 11 } 215 | } 216 | }, 217 | y: { 218 | beginAtZero: true, 219 | border: { display: false }, 220 | grid: { 221 | color: 'rgba(229, 231, 235, 0.5)', 222 | drawBorder: false, 223 | lineWidth: 1 224 | }, 225 | ticks: { 226 | color: 'rgb(156, 163, 175)', 227 | padding: 8, 228 | font: { size: 11 }, 229 | callback: value => `${value} ms`, 230 | maxTicksLimit: 8 231 | } 232 | } 233 | }, 234 | elements: { 235 | line: { 236 | tension: 0.4, 237 | borderWidth: 1.5, 238 | borderCapStyle: 'round', 239 | borderJoinStyle: 'round', 240 | capBezierPoints: true 241 | } 242 | }, 243 | animation: { 244 | duration: 600, 245 | easing: 'easeInOutCubic', 246 | delay: (context) => context.dataIndex * 20 247 | } 248 | } -------------------------------------------------------------------------------- /src/components/Card.vue: -------------------------------------------------------------------------------- 1 | 304 | 305 | --------------------------------------------------------------------------------