chore: sync local changes (2026-03-12)
This commit is contained in:
47
AGENTS.md
Normal file
47
AGENTS.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
This repository is split into two apps:
|
||||
- `mengyaping-frontend/`: React + Vite UI. Main code is in `src/` (`components/`, `pages/`, `hooks/`, `services/`), with static assets in `public/`.
|
||||
- `mengyaping-backend/`: Go + Gin API and monitor service. Core folders are `handlers/`, `services/`, `router/`, `models/`, `storage/`, `config/`, and `utils/`.
|
||||
|
||||
Runtime data is persisted under `mengyaping-backend/data/` (`websites.json`, `records.json`, `groups.json`, `config.json`). Keep data format changes backward-compatible.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Frontend (run inside `mengyaping-frontend/`):
|
||||
- `npm install`: install dependencies.
|
||||
- `npm run dev`: start Vite dev server.
|
||||
- `npm run build`: create production build in `dist/`.
|
||||
- `npm run lint`: run ESLint checks.
|
||||
|
||||
Backend (run inside `mengyaping-backend/`):
|
||||
- `go mod tidy`: sync Go modules.
|
||||
- `go run main.go`: start API server (default `0.0.0.0:8080`).
|
||||
- `go test ./...`: run all backend tests.
|
||||
- `docker compose up -d --build`: build and run containerized backend.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Frontend: 2-space indentation, ES module imports, React component files in PascalCase (for example `WebsiteCard.jsx`), hooks in `useXxx.js`, utility/service functions in camelCase.
|
||||
- Backend: format with `gofmt`; keep package names lowercase; exported identifiers in PascalCase, internal helpers in camelCase.
|
||||
- Keep handlers thin and place business logic in `services/`.
|
||||
|
||||
## Testing Guidelines
|
||||
There are currently no committed frontend tests and minimal backend test coverage. Add tests for every non-trivial change:
|
||||
- Backend: `*_test.go` next to implementation; focus on handlers and service logic.
|
||||
- Frontend: if introducing test tooling, prefer Vitest + Testing Library with `*.test.jsx` naming.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Current history uses short, imperative commit text (for example `first commit`). Continue with concise, scoped messages such as:
|
||||
- `feat(frontend): add status filter`
|
||||
- `fix(backend): validate monitor interval`
|
||||
|
||||
Each PR should include:
|
||||
- Clear summary and impacted area (`frontend`, `backend`, or both).
|
||||
- Validation steps and commands run.
|
||||
- Screenshots/GIFs for UI changes.
|
||||
- Linked issue/ticket when available.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Do not commit secrets, tokens, or private endpoints.
|
||||
- Frontend dev API target is `http://localhost:8080/api` in `mengyaping-frontend/src/services/api.js`.
|
||||
- Commit only sanitized sample data in `mengyaping-backend/data/`.
|
||||
110
README.md
Normal file
110
README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 萌芽Ping(MengYaPing)
|
||||
|
||||
一个轻量、可自部署的网站可用性监控面板。
|
||||
支持多网站/多 URL 监控、分组管理、实时状态查看、24h/7d 可用率统计与延迟展示。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 多网站监控:每个网站可配置多个 URL,分别检测
|
||||
- 自动巡检:默认每 5 分钟检测一次(可配置)
|
||||
- 状态面板:在线/离线、状态码、响应延迟、最后检测时间
|
||||
- 可用率统计:按 24 小时与 7 天维度聚合
|
||||
- 分组与检索:支持分组筛选与关键词搜索
|
||||
- 手动触发:支持单网站“立即检测”
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 前端:React 19 + Vite 7 + Tailwind CSS 4
|
||||
- 后端:Go 1.25 + Gin
|
||||
- 存储:本地 JSON 文件(`mengyaping-backend/data/`)
|
||||
- 部署:Docker / Docker Compose(后端)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├─ mengyaping-frontend/ # 前端应用
|
||||
│ ├─ src/components/ # 卡片、图表、弹窗等组件
|
||||
│ ├─ src/pages/ # 页面(Dashboard)
|
||||
│ ├─ src/services/api.js # API 请求封装
|
||||
│ └─ public/ # 静态资源(logo、favicon)
|
||||
├─ mengyaping-backend/ # 后端服务
|
||||
│ ├─ handlers/ services/ router/
|
||||
│ ├─ models/ storage/ config/ utils/
|
||||
│ └─ data/ # 配置与监控数据(JSON)
|
||||
├─ 开启前端.bat
|
||||
├─ 开启后端.bat
|
||||
└─ 构建前端.bat
|
||||
```
|
||||
|
||||
## 快速开始(本地开发)
|
||||
|
||||
### 1) 启动后端
|
||||
|
||||
```bash
|
||||
cd mengyaping-backend
|
||||
go mod tidy
|
||||
go run main.go
|
||||
```
|
||||
|
||||
默认地址:`http://localhost:8080`
|
||||
|
||||
### 2) 启动前端
|
||||
|
||||
```bash
|
||||
cd mengyaping-frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端开发地址通常为:`http://localhost:5173`
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
cd mengyaping-frontend
|
||||
npm run dev # 开发模式
|
||||
npm run build # 生产构建
|
||||
npm run lint # 代码检查
|
||||
|
||||
# 后端
|
||||
cd mengyaping-backend
|
||||
go test ./... # 运行测试
|
||||
```
|
||||
|
||||
Windows 用户也可直接使用仓库根目录下的 `开启前端.bat`、`开启后端.bat`、`构建前端.bat`。
|
||||
|
||||
## API 概览
|
||||
|
||||
基础前缀:`/api`
|
||||
|
||||
- `GET /health`:健康检查
|
||||
- `GET /websites`:获取全部网站状态
|
||||
- `GET /websites/:id`:获取单网站状态
|
||||
- `POST /websites`:创建网站
|
||||
- `PUT /websites/:id`:更新网站
|
||||
- `DELETE /websites/:id`:删除网站
|
||||
- `POST /websites/:id/check`:立即检测
|
||||
- `GET /groups`:获取分组
|
||||
- `POST /groups`:新增分组
|
||||
|
||||
## 配置说明
|
||||
|
||||
后端支持环境变量配置(如 `SERVER_PORT`、`MONITOR_INTERVAL`、`MONITOR_TIMEOUT` 等),并会读取 `mengyaping-backend/data/config.json`。
|
||||
当前实现中,`config.json` 的值会覆盖环境变量同名项。
|
||||
|
||||
## Docker 部署(后端)
|
||||
|
||||
```bash
|
||||
cd mengyaping-backend
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`docker-compose.yml` 默认映射端口 `6161 -> 8080`。
|
||||
|
||||
## 展示建议(GitHub)
|
||||
|
||||
- 建议在仓库中新增 `docs/images/` 并放置页面截图
|
||||
- 可在本 README 顶部补充截图、动图或在线演示链接,提升展示效果
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
# Git 相关
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# 编辑器和 IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 操作系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 数据文件(运行时生成)
|
||||
data/*.json
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# 文档
|
||||
README.md
|
||||
LICENSE
|
||||
*.md
|
||||
|
||||
# Docker 相关
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# 测试文件
|
||||
*_test.go
|
||||
test/
|
||||
tests/
|
||||
|
||||
# 构建产物
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
# Git 相关
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# 编辑器和 IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 操作系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 数据文件(运行时生成)
|
||||
data/*.json
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# 文档
|
||||
README.md
|
||||
LICENSE
|
||||
*.md
|
||||
|
||||
# Docker 相关
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# 测试文件
|
||||
*_test.go
|
||||
test/
|
||||
tests/
|
||||
|
||||
# 构建产物
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
# 多阶段构建 - 使用官方 Golang 镜像作为构建环境
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的构建工具
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# 复制 go.mod 和 go.sum 文件
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# 下载依赖
|
||||
RUN go mod download
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用程序
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o mengyaping-backend .
|
||||
|
||||
# 使用轻量级的 alpine 镜像作为运行环境
|
||||
FROM alpine:latest
|
||||
|
||||
# 安装必要的运行时依赖
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# 设置时区为上海
|
||||
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
echo "Asia/Shanghai" > /etc/timezone
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN addgroup -g 1000 appuser && \
|
||||
adduser -D -u 1000 -G appuser appuser
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 从构建阶段复制编译好的二进制文件
|
||||
COPY --from=builder /app/mengyaping-backend .
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
||||
|
||||
# 切换到非 root 用户
|
||||
USER appuser
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
|
||||
|
||||
# 运行应用程序
|
||||
CMD ["./mengyaping-backend"]
|
||||
# 多阶段构建 - 使用官方 Golang 镜像作为构建环境
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的构建工具
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# 复制 go.mod 和 go.sum 文件
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# 下载依赖
|
||||
RUN go mod download
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用程序
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o mengyaping-backend .
|
||||
|
||||
# 使用轻量级的 alpine 镜像作为运行环境
|
||||
FROM alpine:latest
|
||||
|
||||
# 安装必要的运行时依赖
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# 设置时区为上海
|
||||
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
echo "Asia/Shanghai" > /etc/timezone
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN addgroup -g 1000 appuser && \
|
||||
adduser -D -u 1000 -G appuser appuser
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 从构建阶段复制编译好的二进制文件
|
||||
COPY --from=builder /app/mengyaping-backend .
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
||||
|
||||
# 切换到非 root 用户
|
||||
USER appuser
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
|
||||
|
||||
# 运行应用程序
|
||||
CMD ["./mengyaping-backend"]
|
||||
|
||||
@@ -1,176 +1,176 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config 应用配置
|
||||
type Config struct {
|
||||
Server ServerConfig `json:"server"`
|
||||
Monitor MonitorConfig `json:"monitor"`
|
||||
DataPath string `json:"data_path"`
|
||||
}
|
||||
|
||||
// ServerConfig 服务器配置
|
||||
type ServerConfig struct {
|
||||
Port string `json:"port"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
// MonitorConfig 监控配置
|
||||
type MonitorConfig struct {
|
||||
Interval time.Duration `json:"interval"` // 检测间隔
|
||||
Timeout time.Duration `json:"timeout"` // 请求超时时间
|
||||
RetryCount int `json:"retry_count"` // 重试次数
|
||||
HistoryDays int `json:"history_days"` // 保留历史天数
|
||||
}
|
||||
|
||||
var (
|
||||
cfg *Config
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetConfig 获取配置单例
|
||||
func GetConfig() *Config {
|
||||
once.Do(func() {
|
||||
cfg = &Config{
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("SERVER_PORT", "8080"),
|
||||
Host: getEnv("SERVER_HOST", "0.0.0.0"),
|
||||
},
|
||||
Monitor: MonitorConfig{
|
||||
Interval: parseDuration(getEnv("MONITOR_INTERVAL", "5m"), 5*time.Minute),
|
||||
Timeout: parseDuration(getEnv("MONITOR_TIMEOUT", "10s"), 10*time.Second),
|
||||
RetryCount: parseInt(getEnv("MONITOR_RETRY_COUNT", "3"), 3),
|
||||
HistoryDays: parseInt(getEnv("MONITOR_HISTORY_DAYS", "7"), 7),
|
||||
},
|
||||
DataPath: getEnv("DATA_PATH", "./data"),
|
||||
}
|
||||
|
||||
// 尝试从配置文件加载(会覆盖环境变量配置)
|
||||
loadConfigFromFile()
|
||||
})
|
||||
return cfg
|
||||
}
|
||||
|
||||
// getEnv 获取环境变量,如果不存在则返回默认值
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// parseInt 解析整数环境变量
|
||||
func parseInt(value string, defaultValue int) int {
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
var result int
|
||||
if _, err := fmt.Sscanf(value, "%d", &result); err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseDuration 解析时间间隔环境变量
|
||||
func parseDuration(value string, defaultValue time.Duration) time.Duration {
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
if duration, err := time.ParseDuration(value); err == nil {
|
||||
return duration
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// loadConfigFromFile 从文件加载配置
|
||||
func loadConfigFromFile() {
|
||||
configFile := "./data/config.json"
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var fileCfg struct {
|
||||
Server ServerConfig `json:"server"`
|
||||
Monitor struct {
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
HistoryDays int `json:"history_days"`
|
||||
} `json:"monitor"`
|
||||
DataPath string `json:"data_path"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &fileCfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if fileCfg.Server.Port != "" {
|
||||
cfg.Server.Port = fileCfg.Server.Port
|
||||
}
|
||||
if fileCfg.Server.Host != "" {
|
||||
cfg.Server.Host = fileCfg.Server.Host
|
||||
}
|
||||
if fileCfg.Monitor.IntervalMinutes > 0 {
|
||||
cfg.Monitor.Interval = time.Duration(fileCfg.Monitor.IntervalMinutes) * time.Minute
|
||||
}
|
||||
if fileCfg.Monitor.TimeoutSeconds > 0 {
|
||||
cfg.Monitor.Timeout = time.Duration(fileCfg.Monitor.TimeoutSeconds) * time.Second
|
||||
}
|
||||
if fileCfg.Monitor.RetryCount > 0 {
|
||||
cfg.Monitor.RetryCount = fileCfg.Monitor.RetryCount
|
||||
}
|
||||
if fileCfg.Monitor.HistoryDays > 0 {
|
||||
cfg.Monitor.HistoryDays = fileCfg.Monitor.HistoryDays
|
||||
}
|
||||
if fileCfg.DataPath != "" {
|
||||
cfg.DataPath = fileCfg.DataPath
|
||||
}
|
||||
}
|
||||
|
||||
// SaveConfig 保存配置到文件
|
||||
func SaveConfig() error {
|
||||
configFile := cfg.DataPath + "/config.json"
|
||||
|
||||
fileCfg := struct {
|
||||
Server ServerConfig `json:"server"`
|
||||
Monitor struct {
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
HistoryDays int `json:"history_days"`
|
||||
} `json:"monitor"`
|
||||
DataPath string `json:"data_path"`
|
||||
}{
|
||||
Server: cfg.Server,
|
||||
Monitor: struct {
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
HistoryDays int `json:"history_days"`
|
||||
}{
|
||||
IntervalMinutes: int(cfg.Monitor.Interval.Minutes()),
|
||||
TimeoutSeconds: int(cfg.Monitor.Timeout.Seconds()),
|
||||
RetryCount: cfg.Monitor.RetryCount,
|
||||
HistoryDays: cfg.Monitor.HistoryDays,
|
||||
},
|
||||
DataPath: cfg.DataPath,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(fileCfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(configFile, data, 0644)
|
||||
}
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config 应用配置
|
||||
type Config struct {
|
||||
Server ServerConfig `json:"server"`
|
||||
Monitor MonitorConfig `json:"monitor"`
|
||||
DataPath string `json:"data_path"`
|
||||
}
|
||||
|
||||
// ServerConfig 服务器配置
|
||||
type ServerConfig struct {
|
||||
Port string `json:"port"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
// MonitorConfig 监控配置
|
||||
type MonitorConfig struct {
|
||||
Interval time.Duration `json:"interval"` // 检测间隔
|
||||
Timeout time.Duration `json:"timeout"` // 请求超时时间
|
||||
RetryCount int `json:"retry_count"` // 重试次数
|
||||
HistoryDays int `json:"history_days"` // 保留历史天数
|
||||
}
|
||||
|
||||
var (
|
||||
cfg *Config
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetConfig 获取配置单例
|
||||
func GetConfig() *Config {
|
||||
once.Do(func() {
|
||||
cfg = &Config{
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("SERVER_PORT", "8080"),
|
||||
Host: getEnv("SERVER_HOST", "0.0.0.0"),
|
||||
},
|
||||
Monitor: MonitorConfig{
|
||||
Interval: parseDuration(getEnv("MONITOR_INTERVAL", "1h"), 1*time.Hour),
|
||||
Timeout: parseDuration(getEnv("MONITOR_TIMEOUT", "10s"), 10*time.Second),
|
||||
RetryCount: parseInt(getEnv("MONITOR_RETRY_COUNT", "3"), 3),
|
||||
HistoryDays: parseInt(getEnv("MONITOR_HISTORY_DAYS", "90"), 90),
|
||||
},
|
||||
DataPath: getEnv("DATA_PATH", "./data"),
|
||||
}
|
||||
|
||||
// 尝试从配置文件加载(会覆盖环境变量配置)
|
||||
loadConfigFromFile()
|
||||
})
|
||||
return cfg
|
||||
}
|
||||
|
||||
// getEnv 获取环境变量,如果不存在则返回默认值
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// parseInt 解析整数环境变量
|
||||
func parseInt(value string, defaultValue int) int {
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
var result int
|
||||
if _, err := fmt.Sscanf(value, "%d", &result); err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseDuration 解析时间间隔环境变量
|
||||
func parseDuration(value string, defaultValue time.Duration) time.Duration {
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
if duration, err := time.ParseDuration(value); err == nil {
|
||||
return duration
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// loadConfigFromFile 从文件加载配置
|
||||
func loadConfigFromFile() {
|
||||
configFile := "./data/config.json"
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var fileCfg struct {
|
||||
Server ServerConfig `json:"server"`
|
||||
Monitor struct {
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
HistoryDays int `json:"history_days"`
|
||||
} `json:"monitor"`
|
||||
DataPath string `json:"data_path"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &fileCfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if fileCfg.Server.Port != "" {
|
||||
cfg.Server.Port = fileCfg.Server.Port
|
||||
}
|
||||
if fileCfg.Server.Host != "" {
|
||||
cfg.Server.Host = fileCfg.Server.Host
|
||||
}
|
||||
if fileCfg.Monitor.IntervalMinutes > 0 {
|
||||
cfg.Monitor.Interval = time.Duration(fileCfg.Monitor.IntervalMinutes) * time.Minute
|
||||
}
|
||||
if fileCfg.Monitor.TimeoutSeconds > 0 {
|
||||
cfg.Monitor.Timeout = time.Duration(fileCfg.Monitor.TimeoutSeconds) * time.Second
|
||||
}
|
||||
if fileCfg.Monitor.RetryCount > 0 {
|
||||
cfg.Monitor.RetryCount = fileCfg.Monitor.RetryCount
|
||||
}
|
||||
if fileCfg.Monitor.HistoryDays > 0 {
|
||||
cfg.Monitor.HistoryDays = fileCfg.Monitor.HistoryDays
|
||||
}
|
||||
if fileCfg.DataPath != "" {
|
||||
cfg.DataPath = fileCfg.DataPath
|
||||
}
|
||||
}
|
||||
|
||||
// SaveConfig 保存配置到文件
|
||||
func SaveConfig() error {
|
||||
configFile := cfg.DataPath + "/config.json"
|
||||
|
||||
fileCfg := struct {
|
||||
Server ServerConfig `json:"server"`
|
||||
Monitor struct {
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
HistoryDays int `json:"history_days"`
|
||||
} `json:"monitor"`
|
||||
DataPath string `json:"data_path"`
|
||||
}{
|
||||
Server: cfg.Server,
|
||||
Monitor: struct {
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
HistoryDays int `json:"history_days"`
|
||||
}{
|
||||
IntervalMinutes: int(cfg.Monitor.Interval.Minutes()),
|
||||
TimeoutSeconds: int(cfg.Monitor.Timeout.Seconds()),
|
||||
RetryCount: cfg.Monitor.RetryCount,
|
||||
HistoryDays: cfg.Monitor.HistoryDays,
|
||||
},
|
||||
DataPath: cfg.DataPath,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(fileCfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(configFile, data, 0644)
|
||||
}
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mengyaping-backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mengyaping-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6161:8080"
|
||||
volumes:
|
||||
# 持久化数据目录
|
||||
- /shumengya/docker/mengyaping-backend/data/:/app/data
|
||||
environment:
|
||||
# 服务器配置
|
||||
- SERVER_PORT=8080
|
||||
- SERVER_HOST=0.0.0.0
|
||||
# 监控配置
|
||||
- MONITOR_INTERVAL=5m
|
||||
- MONITOR_TIMEOUT=10s
|
||||
- MONITOR_RETRY_COUNT=3
|
||||
- MONITOR_HISTORY_DAYS=7
|
||||
networks:
|
||||
- mengyaping-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
mengyaping-network:
|
||||
driver: bridge
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mengyaping-backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mengyaping-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6161:8080"
|
||||
volumes:
|
||||
# 持久化数据目录
|
||||
- /shumengya/docker/mengyaping-backend/data/:/app/data
|
||||
environment:
|
||||
# 服务器配置
|
||||
- SERVER_PORT=8080
|
||||
- SERVER_HOST=0.0.0.0
|
||||
# 监控配置
|
||||
- MONITOR_INTERVAL=5m
|
||||
- MONITOR_TIMEOUT=10s
|
||||
- MONITOR_RETRY_COUNT=3
|
||||
- MONITOR_HISTORY_DAYS=7
|
||||
networks:
|
||||
- mengyaping-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
mengyaping-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,199 +1,199 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyaping-backend/models"
|
||||
"mengyaping-backend/services"
|
||||
)
|
||||
|
||||
// WebsiteHandler 网站处理器
|
||||
type WebsiteHandler struct {
|
||||
websiteService *services.WebsiteService
|
||||
monitorService *services.MonitorService
|
||||
}
|
||||
|
||||
// NewWebsiteHandler 创建网站处理器
|
||||
func NewWebsiteHandler() *WebsiteHandler {
|
||||
return &WebsiteHandler{
|
||||
websiteService: services.NewWebsiteService(),
|
||||
monitorService: services.GetMonitorService(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetWebsites 获取所有网站状态
|
||||
func (h *WebsiteHandler) GetWebsites(c *gin.Context) {
|
||||
statuses := h.monitorService.GetAllWebsiteStatuses()
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "success",
|
||||
Data: statuses,
|
||||
})
|
||||
}
|
||||
|
||||
// GetWebsite 获取单个网站状态
|
||||
func (h *WebsiteHandler) GetWebsite(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
status := h.monitorService.GetWebsiteStatus(id)
|
||||
if status == nil {
|
||||
c.JSON(http.StatusNotFound, models.APIResponse{
|
||||
Code: 404,
|
||||
Message: "网站不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "success",
|
||||
Data: status,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateWebsite 创建网站
|
||||
func (h *WebsiteHandler) CreateWebsite(c *gin.Context) {
|
||||
var req models.CreateWebsiteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||
Code: 400,
|
||||
Message: "参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
website, err := h.websiteService.CreateWebsite(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||
Code: 500,
|
||||
Message: "创建失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "创建成功",
|
||||
Data: website,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateWebsite 更新网站
|
||||
func (h *WebsiteHandler) UpdateWebsite(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var req models.UpdateWebsiteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||
Code: 400,
|
||||
Message: "参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
website, err := h.websiteService.UpdateWebsite(id, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||
Code: 500,
|
||||
Message: "更新失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if website == nil {
|
||||
c.JSON(http.StatusNotFound, models.APIResponse{
|
||||
Code: 404,
|
||||
Message: "网站不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "更新成功",
|
||||
Data: website,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteWebsite 删除网站
|
||||
func (h *WebsiteHandler) DeleteWebsite(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
if err := h.websiteService.DeleteWebsite(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||
Code: 500,
|
||||
Message: "删除失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
// CheckWebsiteNow 立即检测网站
|
||||
func (h *WebsiteHandler) CheckWebsiteNow(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
website := h.websiteService.GetWebsite(id)
|
||||
if website == nil {
|
||||
c.JSON(http.StatusNotFound, models.APIResponse{
|
||||
Code: 404,
|
||||
Message: "网站不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.monitorService.CheckWebsiteNow(id)
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "检测任务已提交",
|
||||
})
|
||||
}
|
||||
|
||||
// GetGroups 获取所有分组
|
||||
func (h *WebsiteHandler) GetGroups(c *gin.Context) {
|
||||
groups := h.websiteService.GetGroups()
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "success",
|
||||
Data: groups,
|
||||
})
|
||||
}
|
||||
|
||||
// AddGroup 添加分组
|
||||
func (h *WebsiteHandler) AddGroup(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||
Code: 400,
|
||||
Message: "参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.websiteService.AddGroup(req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||
Code: 500,
|
||||
Message: "添加失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "添加成功",
|
||||
Data: group,
|
||||
})
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyaping-backend/models"
|
||||
"mengyaping-backend/services"
|
||||
)
|
||||
|
||||
// WebsiteHandler 网站处理器
|
||||
type WebsiteHandler struct {
|
||||
websiteService *services.WebsiteService
|
||||
monitorService *services.MonitorService
|
||||
}
|
||||
|
||||
// NewWebsiteHandler 创建网站处理器
|
||||
func NewWebsiteHandler() *WebsiteHandler {
|
||||
return &WebsiteHandler{
|
||||
websiteService: services.NewWebsiteService(),
|
||||
monitorService: services.GetMonitorService(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetWebsites 获取所有网站状态
|
||||
func (h *WebsiteHandler) GetWebsites(c *gin.Context) {
|
||||
statuses := h.monitorService.GetAllWebsiteStatuses()
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "success",
|
||||
Data: statuses,
|
||||
})
|
||||
}
|
||||
|
||||
// GetWebsite 获取单个网站状态
|
||||
func (h *WebsiteHandler) GetWebsite(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
status := h.monitorService.GetWebsiteStatus(id)
|
||||
if status == nil {
|
||||
c.JSON(http.StatusNotFound, models.APIResponse{
|
||||
Code: 404,
|
||||
Message: "网站不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "success",
|
||||
Data: status,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateWebsite 创建网站
|
||||
func (h *WebsiteHandler) CreateWebsite(c *gin.Context) {
|
||||
var req models.CreateWebsiteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||
Code: 400,
|
||||
Message: "参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
website, err := h.websiteService.CreateWebsite(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||
Code: 500,
|
||||
Message: "创建失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "创建成功",
|
||||
Data: website,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateWebsite 更新网站
|
||||
func (h *WebsiteHandler) UpdateWebsite(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var req models.UpdateWebsiteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||
Code: 400,
|
||||
Message: "参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
website, err := h.websiteService.UpdateWebsite(id, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||
Code: 500,
|
||||
Message: "更新失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if website == nil {
|
||||
c.JSON(http.StatusNotFound, models.APIResponse{
|
||||
Code: 404,
|
||||
Message: "网站不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "更新成功",
|
||||
Data: website,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteWebsite 删除网站
|
||||
func (h *WebsiteHandler) DeleteWebsite(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
if err := h.websiteService.DeleteWebsite(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||
Code: 500,
|
||||
Message: "删除失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
// CheckWebsiteNow 立即检测网站
|
||||
func (h *WebsiteHandler) CheckWebsiteNow(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
website := h.websiteService.GetWebsite(id)
|
||||
if website == nil {
|
||||
c.JSON(http.StatusNotFound, models.APIResponse{
|
||||
Code: 404,
|
||||
Message: "网站不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.monitorService.CheckWebsiteNow(id)
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "检测任务已提交",
|
||||
})
|
||||
}
|
||||
|
||||
// GetGroups 获取所有分组
|
||||
func (h *WebsiteHandler) GetGroups(c *gin.Context) {
|
||||
groups := h.websiteService.GetGroups()
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "success",
|
||||
Data: groups,
|
||||
})
|
||||
}
|
||||
|
||||
// AddGroup 添加分组
|
||||
func (h *WebsiteHandler) AddGroup(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||
Code: 400,
|
||||
Message: "参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.websiteService.AddGroup(req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||
Code: 500,
|
||||
Message: "添加失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: 0,
|
||||
Message: "添加成功",
|
||||
Data: group,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"mengyaping-backend/config"
|
||||
"mengyaping-backend/router"
|
||||
"mengyaping-backend/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 获取配置
|
||||
cfg := config.GetConfig()
|
||||
|
||||
// 确保数据目录存在
|
||||
os.MkdirAll(cfg.DataPath, 0755)
|
||||
|
||||
// 启动监控服务
|
||||
monitorService := services.GetMonitorService()
|
||||
go monitorService.Start()
|
||||
|
||||
// 设置路由
|
||||
r := router.SetupRouter()
|
||||
|
||||
// 优雅关闭
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
log.Println("正在关闭服务...")
|
||||
monitorService.Stop()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// 启动服务器
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
|
||||
log.Printf("🌱 萌芽Ping 监控服务已启动,监听地址: %s\n", addr)
|
||||
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"mengyaping-backend/config"
|
||||
"mengyaping-backend/router"
|
||||
"mengyaping-backend/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 获取配置
|
||||
cfg := config.GetConfig()
|
||||
|
||||
// 确保数据目录存在
|
||||
os.MkdirAll(cfg.DataPath, 0755)
|
||||
|
||||
// 启动监控服务
|
||||
monitorService := services.GetMonitorService()
|
||||
go monitorService.Start()
|
||||
|
||||
// 设置路由
|
||||
r := router.SetupRouter()
|
||||
|
||||
// 优雅关闭
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
log.Println("正在关闭服务...")
|
||||
monitorService.Stop()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// 启动服务器
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
|
||||
log.Printf("🌱 萌芽Ping 监控服务已启动,监听地址: %s\n", addr)
|
||||
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +1,113 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Website 网站信息
|
||||
type Website struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // 网站名称
|
||||
Group string `json:"group"` // 所属分组
|
||||
URLs []URLInfo `json:"urls"` // 网站访问地址列表
|
||||
Favicon string `json:"favicon"` // 网站图标URL
|
||||
Title string `json:"title"` // 网站标题
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// URLInfo 单个URL的信息
|
||||
type URLInfo struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"` // 访问地址
|
||||
Remark string `json:"remark"` // 备注说明
|
||||
}
|
||||
|
||||
// MonitorRecord 监控记录
|
||||
type MonitorRecord struct {
|
||||
WebsiteID string `json:"website_id"`
|
||||
URLID string `json:"url_id"`
|
||||
URL string `json:"url"`
|
||||
StatusCode int `json:"status_code"` // HTTP状态码
|
||||
Latency int64 `json:"latency"` // 延迟(毫秒)
|
||||
IsUp bool `json:"is_up"` // 是否可访问
|
||||
Error string `json:"error"` // 错误信息
|
||||
CheckedAt time.Time `json:"checked_at"` // 检测时间
|
||||
}
|
||||
|
||||
// WebsiteStatus 网站状态(用于前端展示)
|
||||
type WebsiteStatus struct {
|
||||
Website Website `json:"website"`
|
||||
URLStatuses []URLStatus `json:"url_statuses"`
|
||||
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
|
||||
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
|
||||
LastChecked time.Time `json:"last_checked"` // 最后检测时间
|
||||
}
|
||||
|
||||
// URLStatus 单个URL的状态
|
||||
type URLStatus struct {
|
||||
URLInfo URLInfo `json:"url_info"`
|
||||
CurrentState MonitorRecord `json:"current_state"` // 当前状态
|
||||
History24h []MonitorRecord `json:"history_24h"` // 24小时历史
|
||||
History7d []HourlyStats `json:"history_7d"` // 7天按小时统计
|
||||
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
|
||||
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
|
||||
AvgLatency int64 `json:"avg_latency"` // 平均延迟
|
||||
}
|
||||
|
||||
// HourlyStats 每小时统计
|
||||
type HourlyStats struct {
|
||||
Hour time.Time `json:"hour"`
|
||||
TotalCount int `json:"total_count"`
|
||||
UpCount int `json:"up_count"`
|
||||
AvgLatency int64 `json:"avg_latency"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
}
|
||||
|
||||
// Group 分组
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// DefaultGroups 默认分组
|
||||
var DefaultGroups = []Group{
|
||||
{ID: "normal", Name: "普通网站"},
|
||||
{ID: "admin", Name: "管理员网站"},
|
||||
}
|
||||
|
||||
// CreateWebsiteRequest 创建网站请求
|
||||
type CreateWebsiteRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Group string `json:"group" binding:"required"`
|
||||
URLs []string `json:"urls" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// UpdateWebsiteRequest 更新网站请求
|
||||
type UpdateWebsiteRequest struct {
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group"`
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
// APIResponse API响应
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Website 网站信息
|
||||
type Website struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // 网站名称
|
||||
Groups []string `json:"groups"` // 所属分组列表(支持多分组)
|
||||
Group string `json:"group,omitempty"` // 已废弃,仅用于旧数据兼容
|
||||
URLs []URLInfo `json:"urls"` // 网站访问地址列表
|
||||
IPAddresses []string `json:"ip_addresses,omitempty"` // 域名解析的IP地址
|
||||
Favicon string `json:"favicon"` // 网站图标URL
|
||||
Title string `json:"title"` // 网站标题
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// URLInfo 单个URL的信息
|
||||
type URLInfo struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"` // 访问地址
|
||||
Remark string `json:"remark"` // 备注说明
|
||||
}
|
||||
|
||||
// MonitorRecord 监控记录
|
||||
type MonitorRecord struct {
|
||||
WebsiteID string `json:"website_id"`
|
||||
URLID string `json:"url_id"`
|
||||
URL string `json:"url"`
|
||||
StatusCode int `json:"status_code"` // HTTP状态码
|
||||
Latency int64 `json:"latency"` // 延迟(毫秒)
|
||||
IsUp bool `json:"is_up"` // 是否可访问
|
||||
Error string `json:"error"` // 错误信息
|
||||
CheckedAt time.Time `json:"checked_at"` // 检测时间
|
||||
}
|
||||
|
||||
// WebsiteStatus 网站状态(用于前端展示)
|
||||
type WebsiteStatus struct {
|
||||
Website Website `json:"website"`
|
||||
URLStatuses []URLStatus `json:"url_statuses"`
|
||||
DailyHistory []DailyStats `json:"daily_history"` // 90天逐日统计
|
||||
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
|
||||
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
|
||||
Uptime90d float64 `json:"uptime_90d"` // 90天可用率
|
||||
LastChecked time.Time `json:"last_checked"` // 最后检测时间
|
||||
}
|
||||
|
||||
// URLStatus 单个URL的状态
|
||||
type URLStatus struct {
|
||||
URLInfo URLInfo `json:"url_info"`
|
||||
CurrentState MonitorRecord `json:"current_state"` // 当前状态
|
||||
History24h []MonitorRecord `json:"history_24h"` // 24小时历史
|
||||
History7d []HourlyStats `json:"history_7d"` // 7天按小时统计
|
||||
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
|
||||
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
|
||||
AvgLatency int64 `json:"avg_latency"` // 平均延迟
|
||||
}
|
||||
|
||||
// HourlyStats 每小时统计
|
||||
type HourlyStats struct {
|
||||
Hour time.Time `json:"hour"`
|
||||
TotalCount int `json:"total_count"`
|
||||
UpCount int `json:"up_count"`
|
||||
AvgLatency int64 `json:"avg_latency"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
}
|
||||
|
||||
// DailyStats 每日统计
|
||||
type DailyStats struct {
|
||||
Date time.Time `json:"date"`
|
||||
TotalCount int `json:"total_count"`
|
||||
UpCount int `json:"up_count"`
|
||||
AvgLatency int64 `json:"avg_latency"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
}
|
||||
|
||||
// Group 分组
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// DefaultGroups 默认分组
|
||||
var DefaultGroups = []Group{
|
||||
{ID: "normal", Name: "普通网站"},
|
||||
{ID: "admin", Name: "管理员网站"},
|
||||
}
|
||||
|
||||
// CreateWebsiteRequest 创建网站请求
|
||||
type CreateWebsiteRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Groups []string `json:"groups" binding:"required,min=1"`
|
||||
Group string `json:"group"`
|
||||
URLs []string `json:"urls" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// UpdateWebsiteRequest 更新网站请求
|
||||
type UpdateWebsiteRequest struct {
|
||||
Name string `json:"name"`
|
||||
Groups []string `json:"groups"`
|
||||
Group string `json:"group"`
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
// APIResponse API响应
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyaping-backend/handlers"
|
||||
)
|
||||
|
||||
// SetupRouter 设置路由
|
||||
func SetupRouter() *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
// CORS配置
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// 创建处理器
|
||||
websiteHandler := handlers.NewWebsiteHandler()
|
||||
|
||||
// API路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 健康检查
|
||||
api.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"message": "服务运行正常",
|
||||
})
|
||||
})
|
||||
|
||||
// 网站相关
|
||||
api.GET("/websites", websiteHandler.GetWebsites)
|
||||
api.GET("/websites/:id", websiteHandler.GetWebsite)
|
||||
api.POST("/websites", websiteHandler.CreateWebsite)
|
||||
api.PUT("/websites/:id", websiteHandler.UpdateWebsite)
|
||||
api.DELETE("/websites/:id", websiteHandler.DeleteWebsite)
|
||||
api.POST("/websites/:id/check", websiteHandler.CheckWebsiteNow)
|
||||
|
||||
// 分组相关
|
||||
api.GET("/groups", websiteHandler.GetGroups)
|
||||
api.POST("/groups", websiteHandler.AddGroup)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyaping-backend/handlers"
|
||||
)
|
||||
|
||||
// SetupRouter 设置路由
|
||||
func SetupRouter() *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
// CORS配置
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// 创建处理器
|
||||
websiteHandler := handlers.NewWebsiteHandler()
|
||||
|
||||
// API路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 健康检查
|
||||
api.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"message": "服务运行正常",
|
||||
})
|
||||
})
|
||||
|
||||
// 网站相关
|
||||
api.GET("/websites", websiteHandler.GetWebsites)
|
||||
api.GET("/websites/:id", websiteHandler.GetWebsite)
|
||||
api.POST("/websites", websiteHandler.CreateWebsite)
|
||||
api.PUT("/websites/:id", websiteHandler.UpdateWebsite)
|
||||
api.DELETE("/websites/:id", websiteHandler.DeleteWebsite)
|
||||
api.POST("/websites/:id/check", websiteHandler.CheckWebsiteNow)
|
||||
|
||||
// 分组相关
|
||||
api.GET("/groups", websiteHandler.GetGroups)
|
||||
api.POST("/groups", websiteHandler.AddGroup)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -1,302 +1,418 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mengyaping-backend/config"
|
||||
"mengyaping-backend/models"
|
||||
"mengyaping-backend/storage"
|
||||
"mengyaping-backend/utils"
|
||||
)
|
||||
|
||||
// MonitorService 监控服务
|
||||
type MonitorService struct {
|
||||
httpClient *utils.HTTPClient
|
||||
storage *storage.Storage
|
||||
stopCh chan struct{}
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var (
|
||||
monitorService *MonitorService
|
||||
monitorOnce sync.Once
|
||||
)
|
||||
|
||||
// GetMonitorService 获取监控服务单例
|
||||
func GetMonitorService() *MonitorService {
|
||||
monitorOnce.Do(func() {
|
||||
cfg := config.GetConfig()
|
||||
monitorService = &MonitorService{
|
||||
httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout),
|
||||
storage: storage.GetStorage(),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
})
|
||||
return monitorService
|
||||
}
|
||||
|
||||
// Start 启动监控服务
|
||||
func (s *MonitorService) Start() {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Println("监控服务已启动")
|
||||
|
||||
// 立即执行一次检测
|
||||
go s.checkAll()
|
||||
|
||||
// 定时检测
|
||||
cfg := config.GetConfig()
|
||||
ticker := time.NewTicker(cfg.Monitor.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
go s.checkAll()
|
||||
case <-s.stopCh:
|
||||
log.Println("监控服务已停止")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止监控服务
|
||||
func (s *MonitorService) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
close(s.stopCh)
|
||||
s.running = false
|
||||
}
|
||||
}
|
||||
|
||||
// checkAll 检查所有网站
|
||||
func (s *MonitorService) checkAll() {
|
||||
websites := s.storage.GetWebsites()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 10) // 限制并发数
|
||||
|
||||
for _, website := range websites {
|
||||
for _, urlInfo := range website.URLs {
|
||||
wg.Add(1)
|
||||
go func(w models.Website, u models.URLInfo) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
s.checkURL(w, u)
|
||||
}(website, urlInfo)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 保存记录
|
||||
s.storage.SaveAll()
|
||||
}
|
||||
|
||||
// checkURL 检查单个URL
|
||||
func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
|
||||
result := s.httpClient.CheckWebsite(urlInfo.URL)
|
||||
|
||||
record := models.MonitorRecord{
|
||||
WebsiteID: website.ID,
|
||||
URLID: urlInfo.ID,
|
||||
URL: urlInfo.URL,
|
||||
StatusCode: result.StatusCode,
|
||||
Latency: result.Latency.Milliseconds(),
|
||||
IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode),
|
||||
CheckedAt: time.Now(),
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
record.Error = result.Error.Error()
|
||||
}
|
||||
|
||||
s.storage.AddRecord(record)
|
||||
|
||||
// 更新网站信息(标题和Favicon)
|
||||
if result.Title != "" || result.Favicon != "" {
|
||||
w := s.storage.GetWebsite(website.ID)
|
||||
if w != nil {
|
||||
needUpdate := false
|
||||
if result.Title != "" && w.Title != result.Title {
|
||||
w.Title = result.Title
|
||||
needUpdate = true
|
||||
}
|
||||
if result.Favicon != "" && w.Favicon != result.Favicon {
|
||||
w.Favicon = result.Favicon
|
||||
needUpdate = true
|
||||
}
|
||||
if needUpdate {
|
||||
w.UpdatedAt = time.Now()
|
||||
s.storage.UpdateWebsite(*w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v",
|
||||
website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp)
|
||||
}
|
||||
|
||||
// CheckWebsiteNow 立即检查指定网站
|
||||
func (s *MonitorService) CheckWebsiteNow(websiteID string) {
|
||||
website := s.storage.GetWebsite(websiteID)
|
||||
if website == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, urlInfo := range website.URLs {
|
||||
go s.checkURL(*website, urlInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// GetWebsiteStatus 获取网站状态
|
||||
func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus {
|
||||
website := s.storage.GetWebsite(websiteID)
|
||||
if website == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
status := &models.WebsiteStatus{
|
||||
Website: *website,
|
||||
URLStatuses: []models.URLStatus{},
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
since24h := now.Add(-24 * time.Hour)
|
||||
since7d := now.Add(-7 * 24 * time.Hour)
|
||||
|
||||
var totalUptime24h, totalUptime7d float64
|
||||
var urlCount int
|
||||
|
||||
for _, urlInfo := range website.URLs {
|
||||
urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d)
|
||||
status.URLStatuses = append(status.URLStatuses, urlStatus)
|
||||
|
||||
totalUptime24h += urlStatus.Uptime24h
|
||||
totalUptime7d += urlStatus.Uptime7d
|
||||
urlCount++
|
||||
}
|
||||
|
||||
if urlCount > 0 {
|
||||
status.Uptime24h = totalUptime24h / float64(urlCount)
|
||||
status.Uptime7d = totalUptime7d / float64(urlCount)
|
||||
}
|
||||
|
||||
// 获取最后检测时间
|
||||
for _, urlStatus := range status.URLStatuses {
|
||||
if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) {
|
||||
status.LastChecked = urlStatus.CurrentState.CheckedAt
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// getURLStatus 获取URL状态
|
||||
func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus {
|
||||
urlStatus := models.URLStatus{
|
||||
URLInfo: urlInfo,
|
||||
}
|
||||
|
||||
// 获取最新记录
|
||||
latest := s.storage.GetLatestRecord(websiteID, urlInfo.ID)
|
||||
if latest != nil {
|
||||
urlStatus.CurrentState = *latest
|
||||
}
|
||||
|
||||
// 获取24小时记录
|
||||
records24h := s.storage.GetRecords(websiteID, urlInfo.ID, since24h)
|
||||
urlStatus.History24h = records24h
|
||||
|
||||
// 计算24小时可用率
|
||||
if len(records24h) > 0 {
|
||||
upCount := 0
|
||||
var totalLatency int64
|
||||
for _, r := range records24h {
|
||||
if r.IsUp {
|
||||
upCount++
|
||||
}
|
||||
totalLatency += r.Latency
|
||||
}
|
||||
urlStatus.Uptime24h = float64(upCount) / float64(len(records24h)) * 100
|
||||
urlStatus.AvgLatency = totalLatency / int64(len(records24h))
|
||||
}
|
||||
|
||||
// 获取7天记录并按小时统计
|
||||
records7d := s.storage.GetRecords(websiteID, urlInfo.ID, since7d)
|
||||
urlStatus.History7d = s.aggregateByHour(records7d)
|
||||
|
||||
// 计算7天可用率
|
||||
if len(records7d) > 0 {
|
||||
upCount := 0
|
||||
for _, r := range records7d {
|
||||
if r.IsUp {
|
||||
upCount++
|
||||
}
|
||||
}
|
||||
urlStatus.Uptime7d = float64(upCount) / float64(len(records7d)) * 100
|
||||
}
|
||||
|
||||
return urlStatus
|
||||
}
|
||||
|
||||
// aggregateByHour 按小时聚合记录
|
||||
func (s *MonitorService) aggregateByHour(records []models.MonitorRecord) []models.HourlyStats {
|
||||
hourlyMap := make(map[string]*models.HourlyStats)
|
||||
|
||||
for _, r := range records {
|
||||
hourKey := r.CheckedAt.Truncate(time.Hour).Format(time.RFC3339)
|
||||
|
||||
if _, exists := hourlyMap[hourKey]; !exists {
|
||||
hourlyMap[hourKey] = &models.HourlyStats{
|
||||
Hour: r.CheckedAt.Truncate(time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
stats := hourlyMap[hourKey]
|
||||
stats.TotalCount++
|
||||
if r.IsUp {
|
||||
stats.UpCount++
|
||||
}
|
||||
stats.AvgLatency += r.Latency
|
||||
}
|
||||
|
||||
var result []models.HourlyStats
|
||||
for _, stats := range hourlyMap {
|
||||
if stats.TotalCount > 0 {
|
||||
stats.AvgLatency /= int64(stats.TotalCount)
|
||||
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100
|
||||
}
|
||||
result = append(result, *stats)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAllWebsiteStatuses 获取所有网站状态
|
||||
func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus {
|
||||
websites := s.storage.GetWebsites()
|
||||
var statuses []models.WebsiteStatus
|
||||
|
||||
for _, website := range websites {
|
||||
status := s.GetWebsiteStatus(website.ID)
|
||||
if status != nil {
|
||||
statuses = append(statuses, *status)
|
||||
}
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mengyaping-backend/config"
|
||||
"mengyaping-backend/models"
|
||||
"mengyaping-backend/storage"
|
||||
"mengyaping-backend/utils"
|
||||
)
|
||||
|
||||
// MonitorService 监控服务
|
||||
type MonitorService struct {
|
||||
httpClient *utils.HTTPClient
|
||||
storage *storage.Storage
|
||||
stopCh chan struct{}
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var (
|
||||
monitorService *MonitorService
|
||||
monitorOnce sync.Once
|
||||
)
|
||||
|
||||
// GetMonitorService 获取监控服务单例
|
||||
func GetMonitorService() *MonitorService {
|
||||
monitorOnce.Do(func() {
|
||||
cfg := config.GetConfig()
|
||||
monitorService = &MonitorService{
|
||||
httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout),
|
||||
storage: storage.GetStorage(),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
})
|
||||
return monitorService
|
||||
}
|
||||
|
||||
// Start 启动监控服务
|
||||
func (s *MonitorService) Start() {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Println("监控服务已启动")
|
||||
|
||||
// 立即执行一次检测
|
||||
go s.checkAll()
|
||||
|
||||
// 定时检测
|
||||
cfg := config.GetConfig()
|
||||
ticker := time.NewTicker(cfg.Monitor.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
go s.checkAll()
|
||||
case <-s.stopCh:
|
||||
log.Println("监控服务已停止")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止监控服务
|
||||
func (s *MonitorService) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
close(s.stopCh)
|
||||
s.running = false
|
||||
}
|
||||
}
|
||||
|
||||
// checkAll 检查所有网站(错峰执行,避免并发暴涨)
|
||||
func (s *MonitorService) checkAll() {
|
||||
websites := s.storage.GetWebsites()
|
||||
semaphore := make(chan struct{}, 3) // 最多 3 个并发检测
|
||||
|
||||
for i, website := range websites {
|
||||
// 每个网站之间间隔 1 秒,把检测分散开
|
||||
if i > 0 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, urlInfo := range website.URLs {
|
||||
wg.Add(1)
|
||||
go func(w models.Website, u models.URLInfo) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
s.checkURL(w, u)
|
||||
}(website, urlInfo)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// 检测完毕后,逐个解析 DNS
|
||||
s.resolveAllWebsiteIPs(websites)
|
||||
|
||||
// 保存记录
|
||||
s.storage.SaveAll()
|
||||
log.Printf("本轮检测完成,共 %d 个网站", len(websites))
|
||||
}
|
||||
|
||||
// resolveAllWebsiteIPs 逐个解析所有网站域名 IP(每次都刷新)
|
||||
func (s *MonitorService) resolveAllWebsiteIPs(websites []models.Website) {
|
||||
for i, website := range websites {
|
||||
if len(website.URLs) == 0 {
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
s.resolveWebsiteIP(website)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveWebsiteIP 解析单个网站的域名 IP
|
||||
func (s *MonitorService) resolveWebsiteIP(website models.Website) {
|
||||
if len(website.URLs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ips, err := utils.ResolveDomainIPs(website.URLs[0].URL)
|
||||
if err != nil {
|
||||
log.Printf("DNS解析失败 [%s]: %v", website.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
w := s.storage.GetWebsite(website.ID)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
w.IPAddresses = ips
|
||||
w.UpdatedAt = time.Now()
|
||||
s.storage.UpdateWebsite(*w)
|
||||
log.Printf("DNS解析 [%s] → %v", website.Name, ips)
|
||||
}
|
||||
|
||||
// checkURL 检查单个URL(带重试)
|
||||
func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
|
||||
cfg := config.GetConfig()
|
||||
maxRetries := cfg.Monitor.RetryCount
|
||||
|
||||
var result utils.CheckResult
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(time.Duration(attempt) * 2 * time.Second)
|
||||
log.Printf("重试 [%s] %s - 第 %d 次重试", website.Name, urlInfo.URL, attempt)
|
||||
}
|
||||
|
||||
result = s.httpClient.CheckWebsiteStatus(urlInfo.URL)
|
||||
if result.Error == nil && utils.IsSuccessStatus(result.StatusCode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
record := models.MonitorRecord{
|
||||
WebsiteID: website.ID,
|
||||
URLID: urlInfo.ID,
|
||||
URL: urlInfo.URL,
|
||||
StatusCode: result.StatusCode,
|
||||
Latency: result.Latency.Milliseconds(),
|
||||
IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode),
|
||||
CheckedAt: time.Now(),
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
record.Error = result.Error.Error()
|
||||
}
|
||||
|
||||
s.storage.AddRecord(record)
|
||||
|
||||
// 仅当网站无标题时才做完整检测来获取元数据
|
||||
if record.IsUp && website.Title == "" {
|
||||
fullResult := s.httpClient.CheckWebsite(urlInfo.URL)
|
||||
if fullResult.Title != "" {
|
||||
w := s.storage.GetWebsite(website.ID)
|
||||
if w != nil {
|
||||
w.Title = fullResult.Title
|
||||
w.UpdatedAt = time.Now()
|
||||
s.storage.UpdateWebsite(*w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v",
|
||||
website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp)
|
||||
}
|
||||
|
||||
// CheckWebsiteNow 立即检查指定网站(状态 + DNS,等待完成后保存)
|
||||
func (s *MonitorService) CheckWebsiteNow(websiteID string) {
|
||||
website := s.storage.GetWebsite(websiteID)
|
||||
if website == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 逐个检测该网站的所有 URL
|
||||
for _, urlInfo := range website.URLs {
|
||||
s.checkURL(*website, urlInfo)
|
||||
}
|
||||
|
||||
// 刷新 DNS
|
||||
s.resolveWebsiteIP(*website)
|
||||
|
||||
s.storage.SaveAll()
|
||||
}
|
||||
|
||||
// GetWebsiteStatus 获取网站状态
|
||||
func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus {
|
||||
website := s.storage.GetWebsite(websiteID)
|
||||
if website == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
status := &models.WebsiteStatus{
|
||||
Website: *website,
|
||||
URLStatuses: []models.URLStatus{},
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
since24h := now.Add(-24 * time.Hour)
|
||||
since7d := now.Add(-7 * 24 * time.Hour)
|
||||
since90d := now.Add(-90 * 24 * time.Hour)
|
||||
|
||||
var totalUptime24h, totalUptime7d float64
|
||||
var urlCount int
|
||||
var allRecords90d []models.MonitorRecord
|
||||
|
||||
for _, urlInfo := range website.URLs {
|
||||
urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d)
|
||||
status.URLStatuses = append(status.URLStatuses, urlStatus)
|
||||
|
||||
totalUptime24h += urlStatus.Uptime24h
|
||||
totalUptime7d += urlStatus.Uptime7d
|
||||
urlCount++
|
||||
|
||||
records90d := s.storage.GetRecords(website.ID, urlInfo.ID, since90d)
|
||||
allRecords90d = append(allRecords90d, records90d...)
|
||||
}
|
||||
|
||||
if urlCount > 0 {
|
||||
status.Uptime24h = totalUptime24h / float64(urlCount)
|
||||
status.Uptime7d = totalUptime7d / float64(urlCount)
|
||||
}
|
||||
|
||||
// 90 天逐日统计
|
||||
status.DailyHistory = s.aggregateByDay(allRecords90d)
|
||||
if len(allRecords90d) > 0 {
|
||||
upCount := 0
|
||||
for _, r := range allRecords90d {
|
||||
if r.IsUp {
|
||||
upCount++
|
||||
}
|
||||
}
|
||||
status.Uptime90d = float64(upCount) / float64(len(allRecords90d)) * 100
|
||||
}
|
||||
|
||||
// 获取最后检测时间
|
||||
for _, urlStatus := range status.URLStatuses {
|
||||
if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) {
|
||||
status.LastChecked = urlStatus.CurrentState.CheckedAt
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// getURLStatus 获取URL状态
|
||||
func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus {
|
||||
urlStatus := models.URLStatus{
|
||||
URLInfo: urlInfo,
|
||||
}
|
||||
|
||||
// 获取最新记录
|
||||
latest := s.storage.GetLatestRecord(websiteID, urlInfo.ID)
|
||||
if latest != nil {
|
||||
urlStatus.CurrentState = *latest
|
||||
}
|
||||
|
||||
// 获取24小时记录
|
||||
records24h := s.storage.GetRecords(websiteID, urlInfo.ID, since24h)
|
||||
urlStatus.History24h = records24h
|
||||
|
||||
// 计算24小时可用率
|
||||
if len(records24h) > 0 {
|
||||
upCount := 0
|
||||
var totalLatency int64
|
||||
for _, r := range records24h {
|
||||
if r.IsUp {
|
||||
upCount++
|
||||
}
|
||||
totalLatency += r.Latency
|
||||
}
|
||||
urlStatus.Uptime24h = float64(upCount) / float64(len(records24h)) * 100
|
||||
urlStatus.AvgLatency = totalLatency / int64(len(records24h))
|
||||
}
|
||||
|
||||
// 获取7天记录并按小时统计
|
||||
records7d := s.storage.GetRecords(websiteID, urlInfo.ID, since7d)
|
||||
urlStatus.History7d = s.aggregateByHour(records7d)
|
||||
|
||||
// 计算7天可用率
|
||||
if len(records7d) > 0 {
|
||||
upCount := 0
|
||||
for _, r := range records7d {
|
||||
if r.IsUp {
|
||||
upCount++
|
||||
}
|
||||
}
|
||||
urlStatus.Uptime7d = float64(upCount) / float64(len(records7d)) * 100
|
||||
}
|
||||
|
||||
return urlStatus
|
||||
}
|
||||
|
||||
// aggregateByHour 按小时聚合记录
|
||||
func (s *MonitorService) aggregateByHour(records []models.MonitorRecord) []models.HourlyStats {
|
||||
hourlyMap := make(map[string]*models.HourlyStats)
|
||||
|
||||
for _, r := range records {
|
||||
hourKey := r.CheckedAt.Truncate(time.Hour).Format(time.RFC3339)
|
||||
|
||||
if _, exists := hourlyMap[hourKey]; !exists {
|
||||
hourlyMap[hourKey] = &models.HourlyStats{
|
||||
Hour: r.CheckedAt.Truncate(time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
stats := hourlyMap[hourKey]
|
||||
stats.TotalCount++
|
||||
if r.IsUp {
|
||||
stats.UpCount++
|
||||
}
|
||||
stats.AvgLatency += r.Latency
|
||||
}
|
||||
|
||||
var result []models.HourlyStats
|
||||
for _, stats := range hourlyMap {
|
||||
if stats.TotalCount > 0 {
|
||||
stats.AvgLatency /= int64(stats.TotalCount)
|
||||
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100
|
||||
}
|
||||
result = append(result, *stats)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// aggregateByDay 按天聚合记录
|
||||
func (s *MonitorService) aggregateByDay(records []models.MonitorRecord) []models.DailyStats {
|
||||
dayMap := make(map[string]*models.DailyStats)
|
||||
|
||||
for _, r := range records {
|
||||
dayTime := time.Date(r.CheckedAt.Year(), r.CheckedAt.Month(), r.CheckedAt.Day(), 0, 0, 0, 0, r.CheckedAt.Location())
|
||||
dayKey := dayTime.Format("2006-01-02")
|
||||
|
||||
if _, exists := dayMap[dayKey]; !exists {
|
||||
dayMap[dayKey] = &models.DailyStats{
|
||||
Date: dayTime,
|
||||
}
|
||||
}
|
||||
|
||||
stats := dayMap[dayKey]
|
||||
stats.TotalCount++
|
||||
if r.IsUp {
|
||||
stats.UpCount++
|
||||
}
|
||||
stats.AvgLatency += r.Latency
|
||||
}
|
||||
|
||||
result := make([]models.DailyStats, 0, len(dayMap))
|
||||
for _, stats := range dayMap {
|
||||
if stats.TotalCount > 0 {
|
||||
stats.AvgLatency /= int64(stats.TotalCount)
|
||||
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100
|
||||
}
|
||||
result = append(result, *stats)
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Date.Before(result[j].Date)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAllWebsiteStatuses 获取所有网站状态
|
||||
func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus {
|
||||
websites := s.storage.GetWebsites()
|
||||
var statuses []models.WebsiteStatus
|
||||
|
||||
for _, website := range websites {
|
||||
status := s.GetWebsiteStatus(website.ID)
|
||||
if status != nil {
|
||||
statuses = append(statuses, *status)
|
||||
}
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
@@ -1,127 +1,142 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mengyaping-backend/models"
|
||||
"mengyaping-backend/storage"
|
||||
"mengyaping-backend/utils"
|
||||
)
|
||||
|
||||
// WebsiteService 网站服务
|
||||
type WebsiteService struct {
|
||||
storage *storage.Storage
|
||||
}
|
||||
|
||||
// NewWebsiteService 创建网站服务
|
||||
func NewWebsiteService() *WebsiteService {
|
||||
return &WebsiteService{
|
||||
storage: storage.GetStorage(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWebsite 创建网站
|
||||
func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) {
|
||||
website := models.Website{
|
||||
ID: utils.GenerateID(),
|
||||
Name: req.Name,
|
||||
Group: req.Group,
|
||||
URLs: make([]models.URLInfo, 0),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
for _, url := range req.URLs {
|
||||
urlInfo := models.URLInfo{
|
||||
ID: utils.GenerateShortID(),
|
||||
URL: url,
|
||||
}
|
||||
website.URLs = append(website.URLs, urlInfo)
|
||||
}
|
||||
|
||||
if err := s.storage.AddWebsite(website); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 立即检测该网站
|
||||
go GetMonitorService().CheckWebsiteNow(website.ID)
|
||||
|
||||
return &website, nil
|
||||
}
|
||||
|
||||
// GetWebsite 获取网站
|
||||
func (s *WebsiteService) GetWebsite(id string) *models.Website {
|
||||
return s.storage.GetWebsite(id)
|
||||
}
|
||||
|
||||
// GetAllWebsites 获取所有网站
|
||||
func (s *WebsiteService) GetAllWebsites() []models.Website {
|
||||
return s.storage.GetWebsites()
|
||||
}
|
||||
|
||||
// UpdateWebsite 更新网站
|
||||
func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteRequest) (*models.Website, error) {
|
||||
website := s.storage.GetWebsite(id)
|
||||
if website == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
website.Name = req.Name
|
||||
}
|
||||
if req.Group != "" {
|
||||
website.Group = req.Group
|
||||
}
|
||||
if len(req.URLs) > 0 {
|
||||
// 保留已有URL的ID,添加新URL
|
||||
existingURLs := make(map[string]models.URLInfo)
|
||||
for _, u := range website.URLs {
|
||||
existingURLs[u.URL] = u
|
||||
}
|
||||
|
||||
newURLs := make([]models.URLInfo, 0)
|
||||
for _, url := range req.URLs {
|
||||
if existing, ok := existingURLs[url]; ok {
|
||||
newURLs = append(newURLs, existing)
|
||||
} else {
|
||||
newURLs = append(newURLs, models.URLInfo{
|
||||
ID: utils.GenerateShortID(),
|
||||
URL: url,
|
||||
})
|
||||
}
|
||||
}
|
||||
website.URLs = newURLs
|
||||
}
|
||||
|
||||
website.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.storage.UpdateWebsite(*website); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return website, nil
|
||||
}
|
||||
|
||||
// DeleteWebsite 删除网站
|
||||
func (s *WebsiteService) DeleteWebsite(id string) error {
|
||||
return s.storage.DeleteWebsite(id)
|
||||
}
|
||||
|
||||
// GetGroups 获取所有分组
|
||||
func (s *WebsiteService) GetGroups() []models.Group {
|
||||
return s.storage.GetGroups()
|
||||
}
|
||||
|
||||
// AddGroup 添加分组
|
||||
func (s *WebsiteService) AddGroup(name string) (*models.Group, error) {
|
||||
group := models.Group{
|
||||
ID: utils.GenerateShortID(),
|
||||
Name: name,
|
||||
}
|
||||
|
||||
if err := s.storage.AddGroup(group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &group, nil
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mengyaping-backend/models"
|
||||
"mengyaping-backend/storage"
|
||||
"mengyaping-backend/utils"
|
||||
)
|
||||
|
||||
// WebsiteService 网站服务
|
||||
type WebsiteService struct {
|
||||
storage *storage.Storage
|
||||
}
|
||||
|
||||
// NewWebsiteService 创建网站服务
|
||||
func NewWebsiteService() *WebsiteService {
|
||||
return &WebsiteService{
|
||||
storage: storage.GetStorage(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWebsite 创建网站
|
||||
func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) {
|
||||
groups := req.Groups
|
||||
if len(groups) == 0 && req.Group != "" {
|
||||
groups = []string{req.Group}
|
||||
}
|
||||
|
||||
website := models.Website{
|
||||
ID: utils.GenerateID(),
|
||||
Name: req.Name,
|
||||
Groups: groups,
|
||||
URLs: make([]models.URLInfo, 0),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
for _, url := range req.URLs {
|
||||
urlInfo := models.URLInfo{
|
||||
ID: utils.GenerateShortID(),
|
||||
URL: url,
|
||||
}
|
||||
website.URLs = append(website.URLs, urlInfo)
|
||||
}
|
||||
|
||||
// 创建前先解析域名 IP
|
||||
if len(req.URLs) > 0 {
|
||||
if ips, err := utils.ResolveDomainIPs(req.URLs[0]); err == nil {
|
||||
website.IPAddresses = ips
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.storage.AddWebsite(website); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 立即检测该网站
|
||||
go GetMonitorService().CheckWebsiteNow(website.ID)
|
||||
|
||||
return &website, nil
|
||||
}
|
||||
|
||||
// GetWebsite 获取网站
|
||||
func (s *WebsiteService) GetWebsite(id string) *models.Website {
|
||||
return s.storage.GetWebsite(id)
|
||||
}
|
||||
|
||||
// GetAllWebsites 获取所有网站
|
||||
func (s *WebsiteService) GetAllWebsites() []models.Website {
|
||||
return s.storage.GetWebsites()
|
||||
}
|
||||
|
||||
// UpdateWebsite 更新网站
|
||||
func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteRequest) (*models.Website, error) {
|
||||
website := s.storage.GetWebsite(id)
|
||||
if website == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
website.Name = req.Name
|
||||
}
|
||||
if len(req.Groups) > 0 {
|
||||
website.Groups = req.Groups
|
||||
} else if req.Group != "" {
|
||||
website.Groups = []string{req.Group}
|
||||
}
|
||||
if len(req.URLs) > 0 {
|
||||
// 保留已有URL的ID,添加新URL
|
||||
existingURLs := make(map[string]models.URLInfo)
|
||||
for _, u := range website.URLs {
|
||||
existingURLs[u.URL] = u
|
||||
}
|
||||
|
||||
newURLs := make([]models.URLInfo, 0)
|
||||
for _, url := range req.URLs {
|
||||
if existing, ok := existingURLs[url]; ok {
|
||||
newURLs = append(newURLs, existing)
|
||||
} else {
|
||||
newURLs = append(newURLs, models.URLInfo{
|
||||
ID: utils.GenerateShortID(),
|
||||
URL: url,
|
||||
})
|
||||
}
|
||||
}
|
||||
website.URLs = newURLs
|
||||
website.IPAddresses = nil // URL 变更后清空 IP,等下次检测重新解析
|
||||
}
|
||||
|
||||
website.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.storage.UpdateWebsite(*website); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return website, nil
|
||||
}
|
||||
|
||||
// DeleteWebsite 删除网站
|
||||
func (s *WebsiteService) DeleteWebsite(id string) error {
|
||||
return s.storage.DeleteWebsite(id)
|
||||
}
|
||||
|
||||
// GetGroups 获取所有分组
|
||||
func (s *WebsiteService) GetGroups() []models.Group {
|
||||
return s.storage.GetGroups()
|
||||
}
|
||||
|
||||
// AddGroup 添加分组
|
||||
func (s *WebsiteService) AddGroup(name string) (*models.Group, error) {
|
||||
group := models.Group{
|
||||
ID: utils.GenerateShortID(),
|
||||
Name: name,
|
||||
}
|
||||
|
||||
if err := s.storage.AddGroup(group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
@@ -1,285 +1,302 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mengyaping-backend/config"
|
||||
"mengyaping-backend/models"
|
||||
)
|
||||
|
||||
// Storage 数据存储
|
||||
type Storage struct {
|
||||
dataPath string
|
||||
mu sync.RWMutex
|
||||
websites []models.Website
|
||||
records map[string][]models.MonitorRecord // key: websiteID_urlID
|
||||
groups []models.Group
|
||||
}
|
||||
|
||||
var (
|
||||
store *Storage
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetStorage 获取存储单例
|
||||
func GetStorage() *Storage {
|
||||
once.Do(func() {
|
||||
cfg := config.GetConfig()
|
||||
store = &Storage{
|
||||
dataPath: cfg.DataPath,
|
||||
websites: []models.Website{},
|
||||
records: make(map[string][]models.MonitorRecord),
|
||||
groups: models.DefaultGroups,
|
||||
}
|
||||
store.ensureDataDir()
|
||||
store.load()
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
// ensureDataDir 确保数据目录存在
|
||||
func (s *Storage) ensureDataDir() {
|
||||
os.MkdirAll(s.dataPath, 0755)
|
||||
}
|
||||
|
||||
// load 加载数据
|
||||
func (s *Storage) load() {
|
||||
s.loadWebsites()
|
||||
s.loadRecords()
|
||||
s.loadGroups()
|
||||
}
|
||||
|
||||
// loadWebsites 加载网站数据
|
||||
func (s *Storage) loadWebsites() {
|
||||
filePath := filepath.Join(s.dataPath, "websites.json")
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
json.Unmarshal(data, &s.websites)
|
||||
}
|
||||
|
||||
// loadRecords 加载监控记录
|
||||
func (s *Storage) loadRecords() {
|
||||
filePath := filepath.Join(s.dataPath, "records.json")
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
json.Unmarshal(data, &s.records)
|
||||
|
||||
// 清理过期记录
|
||||
s.cleanOldRecords()
|
||||
}
|
||||
|
||||
// loadGroups 加载分组
|
||||
func (s *Storage) loadGroups() {
|
||||
filePath := filepath.Join(s.dataPath, "groups.json")
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
// 使用默认分组
|
||||
s.groups = models.DefaultGroups
|
||||
s.saveGroups()
|
||||
return
|
||||
}
|
||||
json.Unmarshal(data, &s.groups)
|
||||
}
|
||||
|
||||
// saveWebsites 保存网站数据
|
||||
func (s *Storage) saveWebsites() error {
|
||||
filePath := filepath.Join(s.dataPath, "websites.json")
|
||||
data, err := json.MarshalIndent(s.websites, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filePath, data, 0644)
|
||||
}
|
||||
|
||||
// saveRecords 保存监控记录
|
||||
func (s *Storage) saveRecords() error {
|
||||
filePath := filepath.Join(s.dataPath, "records.json")
|
||||
data, err := json.MarshalIndent(s.records, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filePath, data, 0644)
|
||||
}
|
||||
|
||||
// saveGroups 保存分组
|
||||
func (s *Storage) saveGroups() error {
|
||||
filePath := filepath.Join(s.dataPath, "groups.json")
|
||||
data, err := json.MarshalIndent(s.groups, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filePath, data, 0644)
|
||||
}
|
||||
|
||||
// cleanOldRecords 清理过期记录
|
||||
func (s *Storage) cleanOldRecords() {
|
||||
cfg := config.GetConfig()
|
||||
cutoff := time.Now().AddDate(0, 0, -cfg.Monitor.HistoryDays)
|
||||
|
||||
for key, records := range s.records {
|
||||
var newRecords []models.MonitorRecord
|
||||
for _, r := range records {
|
||||
if r.CheckedAt.After(cutoff) {
|
||||
newRecords = append(newRecords, r)
|
||||
}
|
||||
}
|
||||
s.records[key] = newRecords
|
||||
}
|
||||
}
|
||||
|
||||
// GetWebsites 获取所有网站
|
||||
func (s *Storage) GetWebsites() []models.Website {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]models.Website, len(s.websites))
|
||||
copy(result, s.websites)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetWebsite 获取单个网站
|
||||
func (s *Storage) GetWebsite(id string) *models.Website {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, w := range s.websites {
|
||||
if w.ID == id {
|
||||
website := w
|
||||
return &website
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddWebsite 添加网站
|
||||
func (s *Storage) AddWebsite(website models.Website) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.websites = append(s.websites, website)
|
||||
return s.saveWebsites()
|
||||
}
|
||||
|
||||
// UpdateWebsite 更新网站
|
||||
func (s *Storage) UpdateWebsite(website models.Website) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, w := range s.websites {
|
||||
if w.ID == website.ID {
|
||||
s.websites[i] = website
|
||||
return s.saveWebsites()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWebsite 删除网站
|
||||
func (s *Storage) DeleteWebsite(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, w := range s.websites {
|
||||
if w.ID == id {
|
||||
s.websites = append(s.websites[:i], s.websites[i+1:]...)
|
||||
// 删除相关记录
|
||||
for key := range s.records {
|
||||
if len(key) > len(id) && key[:len(id)] == id {
|
||||
delete(s.records, key)
|
||||
}
|
||||
}
|
||||
s.saveRecords()
|
||||
return s.saveWebsites()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRecord 添加监控记录
|
||||
func (s *Storage) AddRecord(record models.MonitorRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := record.WebsiteID + "_" + record.URLID
|
||||
s.records[key] = append(s.records[key], record)
|
||||
|
||||
// 每100条记录保存一次
|
||||
if len(s.records[key])%100 == 0 {
|
||||
return s.saveRecords()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRecords 获取监控记录
|
||||
func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
key := websiteID + "_" + urlID
|
||||
records := s.records[key]
|
||||
|
||||
var result []models.MonitorRecord
|
||||
for _, r := range records {
|
||||
if r.CheckedAt.After(since) {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetLatestRecord 获取最新记录
|
||||
func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
key := websiteID + "_" + urlID
|
||||
records := s.records[key]
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
latest := records[len(records)-1]
|
||||
return &latest
|
||||
}
|
||||
|
||||
// GetGroups 获取所有分组
|
||||
func (s *Storage) GetGroups() []models.Group {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]models.Group, len(s.groups))
|
||||
copy(result, s.groups)
|
||||
return result
|
||||
}
|
||||
|
||||
// AddGroup 添加分组
|
||||
func (s *Storage) AddGroup(group models.Group) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.groups = append(s.groups, group)
|
||||
return s.saveGroups()
|
||||
}
|
||||
|
||||
// SaveAll 保存所有数据
|
||||
func (s *Storage) SaveAll() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := s.saveWebsites(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.saveRecords(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.saveGroups()
|
||||
}
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mengyaping-backend/config"
|
||||
"mengyaping-backend/models"
|
||||
)
|
||||
|
||||
// Storage 数据存储
|
||||
type Storage struct {
|
||||
dataPath string
|
||||
mu sync.RWMutex
|
||||
websites []models.Website
|
||||
records map[string][]models.MonitorRecord // key: websiteID_urlID
|
||||
groups []models.Group
|
||||
}
|
||||
|
||||
var (
|
||||
store *Storage
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetStorage 获取存储单例
|
||||
func GetStorage() *Storage {
|
||||
once.Do(func() {
|
||||
cfg := config.GetConfig()
|
||||
store = &Storage{
|
||||
dataPath: cfg.DataPath,
|
||||
websites: []models.Website{},
|
||||
records: make(map[string][]models.MonitorRecord),
|
||||
groups: models.DefaultGroups,
|
||||
}
|
||||
store.ensureDataDir()
|
||||
store.load()
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
// ensureDataDir 确保数据目录存在
|
||||
func (s *Storage) ensureDataDir() {
|
||||
os.MkdirAll(s.dataPath, 0755)
|
||||
}
|
||||
|
||||
// load 加载数据
|
||||
func (s *Storage) load() {
|
||||
s.loadWebsites()
|
||||
s.loadRecords()
|
||||
s.loadGroups()
|
||||
}
|
||||
|
||||
// loadWebsites 加载网站数据
|
||||
func (s *Storage) loadWebsites() {
|
||||
filePath := filepath.Join(s.dataPath, "websites.json")
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
json.Unmarshal(data, &s.websites)
|
||||
s.migrateWebsiteGroups()
|
||||
}
|
||||
|
||||
// migrateWebsiteGroups 将旧的单分组字段迁移到多分组数组
|
||||
func (s *Storage) migrateWebsiteGroups() {
|
||||
migrated := false
|
||||
for i := range s.websites {
|
||||
w := &s.websites[i]
|
||||
if len(w.Groups) == 0 && w.Group != "" {
|
||||
w.Groups = []string{w.Group}
|
||||
w.Group = ""
|
||||
migrated = true
|
||||
}
|
||||
}
|
||||
if migrated {
|
||||
s.saveWebsites()
|
||||
}
|
||||
}
|
||||
|
||||
// loadRecords 加载监控记录
|
||||
func (s *Storage) loadRecords() {
|
||||
filePath := filepath.Join(s.dataPath, "records.json")
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
json.Unmarshal(data, &s.records)
|
||||
|
||||
// 清理过期记录
|
||||
s.cleanOldRecords()
|
||||
}
|
||||
|
||||
// loadGroups 加载分组
|
||||
func (s *Storage) loadGroups() {
|
||||
filePath := filepath.Join(s.dataPath, "groups.json")
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
// 使用默认分组
|
||||
s.groups = models.DefaultGroups
|
||||
s.saveGroups()
|
||||
return
|
||||
}
|
||||
json.Unmarshal(data, &s.groups)
|
||||
}
|
||||
|
||||
// saveWebsites 保存网站数据
|
||||
func (s *Storage) saveWebsites() error {
|
||||
filePath := filepath.Join(s.dataPath, "websites.json")
|
||||
data, err := json.MarshalIndent(s.websites, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filePath, data, 0644)
|
||||
}
|
||||
|
||||
// saveRecords 保存监控记录
|
||||
func (s *Storage) saveRecords() error {
|
||||
filePath := filepath.Join(s.dataPath, "records.json")
|
||||
data, err := json.MarshalIndent(s.records, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filePath, data, 0644)
|
||||
}
|
||||
|
||||
// saveGroups 保存分组
|
||||
func (s *Storage) saveGroups() error {
|
||||
filePath := filepath.Join(s.dataPath, "groups.json")
|
||||
data, err := json.MarshalIndent(s.groups, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filePath, data, 0644)
|
||||
}
|
||||
|
||||
// cleanOldRecords 清理过期记录
|
||||
func (s *Storage) cleanOldRecords() {
|
||||
cfg := config.GetConfig()
|
||||
cutoff := time.Now().AddDate(0, 0, -cfg.Monitor.HistoryDays)
|
||||
|
||||
for key, records := range s.records {
|
||||
var newRecords []models.MonitorRecord
|
||||
for _, r := range records {
|
||||
if r.CheckedAt.After(cutoff) {
|
||||
newRecords = append(newRecords, r)
|
||||
}
|
||||
}
|
||||
s.records[key] = newRecords
|
||||
}
|
||||
}
|
||||
|
||||
// GetWebsites 获取所有网站
|
||||
func (s *Storage) GetWebsites() []models.Website {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]models.Website, len(s.websites))
|
||||
copy(result, s.websites)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetWebsite 获取单个网站
|
||||
func (s *Storage) GetWebsite(id string) *models.Website {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, w := range s.websites {
|
||||
if w.ID == id {
|
||||
website := w
|
||||
return &website
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddWebsite 添加网站
|
||||
func (s *Storage) AddWebsite(website models.Website) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.websites = append(s.websites, website)
|
||||
return s.saveWebsites()
|
||||
}
|
||||
|
||||
// UpdateWebsite 更新网站
|
||||
func (s *Storage) UpdateWebsite(website models.Website) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, w := range s.websites {
|
||||
if w.ID == website.ID {
|
||||
s.websites[i] = website
|
||||
return s.saveWebsites()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWebsite 删除网站
|
||||
func (s *Storage) DeleteWebsite(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, w := range s.websites {
|
||||
if w.ID == id {
|
||||
s.websites = append(s.websites[:i], s.websites[i+1:]...)
|
||||
// 删除相关记录
|
||||
for key := range s.records {
|
||||
if len(key) > len(id) && key[:len(id)] == id {
|
||||
delete(s.records, key)
|
||||
}
|
||||
}
|
||||
s.saveRecords()
|
||||
return s.saveWebsites()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRecord 添加监控记录
|
||||
func (s *Storage) AddRecord(record models.MonitorRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := record.WebsiteID + "_" + record.URLID
|
||||
s.records[key] = append(s.records[key], record)
|
||||
|
||||
// 每100条记录保存一次
|
||||
if len(s.records[key])%100 == 0 {
|
||||
return s.saveRecords()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRecords 获取监控记录
|
||||
func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
key := websiteID + "_" + urlID
|
||||
records := s.records[key]
|
||||
|
||||
var result []models.MonitorRecord
|
||||
for _, r := range records {
|
||||
if r.CheckedAt.After(since) {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetLatestRecord 获取最新记录
|
||||
func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
key := websiteID + "_" + urlID
|
||||
records := s.records[key]
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
latest := records[len(records)-1]
|
||||
return &latest
|
||||
}
|
||||
|
||||
// GetGroups 获取所有分组
|
||||
func (s *Storage) GetGroups() []models.Group {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]models.Group, len(s.groups))
|
||||
copy(result, s.groups)
|
||||
return result
|
||||
}
|
||||
|
||||
// AddGroup 添加分组
|
||||
func (s *Storage) AddGroup(group models.Group) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.groups = append(s.groups, group)
|
||||
return s.saveGroups()
|
||||
}
|
||||
|
||||
// SaveAll 保存所有数据
|
||||
func (s *Storage) SaveAll() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := s.saveWebsites(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.saveRecords(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.saveGroups()
|
||||
}
|
||||
|
||||
60
mengyaping-backend/utils/dns.go
Normal file
60
mengyaping-backend/utils/dns.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dnsAPIBase = "https://cf-dns.smyhub.com/api/dns?domain="
|
||||
|
||||
type dnsResponse struct {
|
||||
Status string `json:"status"`
|
||||
IPv4 []string `json:"ipv4"`
|
||||
IPv6 []string `json:"ipv6"`
|
||||
}
|
||||
|
||||
// ResolveDomainIPs 通过 DNS API 解析域名的 IPv4 + IPv6 地址
|
||||
func ResolveDomainIPs(rawURL string) ([]string, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hostname := parsed.Hostname()
|
||||
if hostname == "" {
|
||||
return nil, fmt.Errorf("no hostname in URL")
|
||||
}
|
||||
|
||||
if net.ParseIP(hostname) != nil {
|
||||
return []string{hostname}, nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(dnsAPIBase + hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*10))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var dnsResp dnsResponse
|
||||
if err := json.Unmarshal(body, &dnsResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dnsResp.Status != "success" {
|
||||
return nil, fmt.Errorf("DNS lookup failed for %s", hostname)
|
||||
}
|
||||
|
||||
ips := append(dnsResp.IPv4, dnsResp.IPv6...)
|
||||
return ips, nil
|
||||
}
|
||||
@@ -1,131 +1,173 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPClient HTTP客户端工具
|
||||
type HTTPClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPClient 创建HTTP客户端
|
||||
func NewHTTPClient(timeout time.Duration) *HTTPClient {
|
||||
return &HTTPClient{
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CheckResult 检查结果
|
||||
type CheckResult struct {
|
||||
StatusCode int
|
||||
Latency time.Duration
|
||||
Title string
|
||||
Favicon string
|
||||
Error error
|
||||
}
|
||||
|
||||
// CheckWebsite 检查网站
|
||||
func (c *HTTPClient) CheckWebsite(targetURL string) CheckResult {
|
||||
result := CheckResult{}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "MengYaPing/1.0 (Website Monitor)")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
result.Latency = time.Since(start)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
result.Latency = time.Since(start)
|
||||
result.StatusCode = resp.StatusCode
|
||||
|
||||
// 读取响应体获取标题
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100)) // 限制100KB
|
||||
if err == nil {
|
||||
result.Title = extractTitle(string(body))
|
||||
result.Favicon = extractFavicon(string(body), targetURL)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractTitle 提取网页标题
|
||||
func extractTitle(html string) string {
|
||||
re := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractFavicon 提取Favicon
|
||||
func extractFavicon(html string, baseURL string) string {
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 尝试从HTML中提取favicon链接
|
||||
patterns := []string{
|
||||
`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']`,
|
||||
`(?i)<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']`,
|
||||
`(?i)<link[^>]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']+)["']`,
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
faviconURL := matches[1]
|
||||
return resolveURL(parsedURL, faviconURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回 /favicon.ico
|
||||
return fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
|
||||
}
|
||||
|
||||
// resolveURL 解析相对URL
|
||||
func resolveURL(base *url.URL, ref string) string {
|
||||
refURL, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return ref
|
||||
}
|
||||
return base.ResolveReference(refURL).String()
|
||||
}
|
||||
|
||||
// IsSuccessStatus 判断是否为成功状态码
|
||||
func IsSuccessStatus(statusCode int) bool {
|
||||
return statusCode >= 200 && statusCode < 400
|
||||
}
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPClient HTTP客户端工具
|
||||
type HTTPClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPClient 创建HTTP客户端
|
||||
func NewHTTPClient(timeout time.Duration) *HTTPClient {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
}
|
||||
|
||||
return &HTTPClient{
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CheckResult 检查结果
|
||||
type CheckResult struct {
|
||||
StatusCode int
|
||||
Latency time.Duration
|
||||
Title string
|
||||
Favicon string
|
||||
Error error
|
||||
}
|
||||
|
||||
// CheckWebsiteStatus 轻量级状态检测(不读取页面内容)
|
||||
func (c *HTTPClient) CheckWebsiteStatus(targetURL string) CheckResult {
|
||||
result := CheckResult{}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "MengYaPing/1.0 (Website Monitor)")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Connection", "close")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
result.Latency = time.Since(start)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
result.Latency = time.Since(start)
|
||||
result.StatusCode = resp.StatusCode
|
||||
|
||||
// 丢弃少量 body 以便连接正确释放
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// CheckWebsite 完整检查(读取页面提取标题等元数据)
|
||||
func (c *HTTPClient) CheckWebsite(targetURL string) CheckResult {
|
||||
result := CheckResult{}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "MengYaPing/1.0 (Website Monitor)")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
result.Latency = time.Since(start)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
result.Latency = time.Since(start)
|
||||
result.StatusCode = resp.StatusCode
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100))
|
||||
if err == nil {
|
||||
result.Title = extractTitle(string(body))
|
||||
result.Favicon = extractFavicon(string(body), targetURL)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractTitle 提取网页标题
|
||||
func extractTitle(html string) string {
|
||||
re := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractFavicon 提取Favicon
|
||||
func extractFavicon(html string, baseURL string) string {
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
patterns := []string{
|
||||
`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']`,
|
||||
`(?i)<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']`,
|
||||
`(?i)<link[^>]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']+)["']`,
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
faviconURL := matches[1]
|
||||
return resolveURL(parsedURL, faviconURL)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
|
||||
}
|
||||
|
||||
// resolveURL 解析相对URL
|
||||
func resolveURL(base *url.URL, ref string) string {
|
||||
refURL, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return ref
|
||||
}
|
||||
return base.ResolveReference(refURL).String()
|
||||
}
|
||||
|
||||
// IsSuccessStatus 判断是否为成功状态码
|
||||
func IsSuccessStatus(statusCode int) bool {
|
||||
return statusCode >= 200 && statusCode < 400
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GenerateID 生成唯一ID
|
||||
func GenerateID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
randomBytes := make([]byte, 4)
|
||||
rand.Read(randomBytes)
|
||||
return hex.EncodeToString([]byte{
|
||||
byte(timestamp >> 56),
|
||||
byte(timestamp >> 48),
|
||||
byte(timestamp >> 40),
|
||||
byte(timestamp >> 32),
|
||||
byte(timestamp >> 24),
|
||||
byte(timestamp >> 16),
|
||||
byte(timestamp >> 8),
|
||||
byte(timestamp),
|
||||
}) + hex.EncodeToString(randomBytes)
|
||||
}
|
||||
|
||||
// GenerateShortID 生成短ID
|
||||
func GenerateShortID() string {
|
||||
randomBytes := make([]byte, 6)
|
||||
rand.Read(randomBytes)
|
||||
return hex.EncodeToString(randomBytes)
|
||||
}
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GenerateID 生成唯一ID
|
||||
func GenerateID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
randomBytes := make([]byte, 4)
|
||||
rand.Read(randomBytes)
|
||||
return hex.EncodeToString([]byte{
|
||||
byte(timestamp >> 56),
|
||||
byte(timestamp >> 48),
|
||||
byte(timestamp >> 40),
|
||||
byte(timestamp >> 32),
|
||||
byte(timestamp >> 24),
|
||||
byte(timestamp >> 16),
|
||||
byte(timestamp >> 8),
|
||||
byte(timestamp),
|
||||
}) + hex.EncodeToString(randomBytes)
|
||||
}
|
||||
|
||||
// GenerateShortID 生成短ID
|
||||
func GenerateShortID() string {
|
||||
randomBytes := make([]byte, 6)
|
||||
rand.Read(randomBytes)
|
||||
return hex.EncodeToString(randomBytes)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="萌芽Ping - 网站监控面板" />
|
||||
<meta name="theme-color" content="#10b981" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title>萌芽Ping - 网站监控面板</title>
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:local": "vite --host 0.0.0.0 --port 5173 --strictPort",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"preview:local": "vite preview --host 0.0.0.0 --port 4173 --strictPort"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
BIN
mengyaping-frontend/public/icons/icon-192.png
Normal file
BIN
mengyaping-frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
mengyaping-frontend/public/icons/icon-512-maskable.png
Normal file
BIN
mengyaping-frontend/public/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
BIN
mengyaping-frontend/public/icons/icon-512.png
Normal file
BIN
mengyaping-frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
32
mengyaping-frontend/public/manifest.webmanifest
Normal file
32
mengyaping-frontend/public/manifest.webmanifest
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "萌芽Ping 网站监控",
|
||||
"short_name": "萌芽Ping",
|
||||
"description": "轻量网站可用性监控面板",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ecfdf5",
|
||||
"theme_color": "#10b981",
|
||||
"lang": "zh-CN",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
70
mengyaping-frontend/public/sw.js
Normal file
70
mengyaping-frontend/public/sw.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const CACHE_NAME = 'mengyaping-shell-v1'
|
||||
const SHELL_FILES = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.webmanifest',
|
||||
'/favicon.ico',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon-512-maskable.png',
|
||||
]
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_FILES))
|
||||
)
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) =>
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter((cacheName) => cacheName !== CACHE_NAME)
|
||||
.map((cacheName) => caches.delete(cacheName))
|
||||
)
|
||||
)
|
||||
)
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
|
||||
// Only cache same-origin requests, leave API calls untouched.
|
||||
if (url.origin !== self.location.origin) {
|
||||
return
|
||||
}
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match('/index.html'))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse
|
||||
}
|
||||
|
||||
return fetch(request).then((networkResponse) => {
|
||||
if (!networkResponse || networkResponse.status !== 200) {
|
||||
return networkResponse
|
||||
}
|
||||
|
||||
const responseClone = networkResponse.clone()
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone))
|
||||
return networkResponse
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import Dashboard from './pages/Dashboard'
|
||||
|
||||
function App() {
|
||||
return <Dashboard />
|
||||
}
|
||||
|
||||
export default App
|
||||
import Dashboard from './pages/Dashboard'
|
||||
|
||||
function App() {
|
||||
return <Dashboard />
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -1,145 +1,145 @@
|
||||
import { formatUptime, getUptimeColor } from '../hooks/useMonitor';
|
||||
|
||||
// 统计概览卡片
|
||||
export default function StatsCard({ websites }) {
|
||||
// 计算统计数据
|
||||
const stats = {
|
||||
total: websites?.length || 0,
|
||||
online: 0,
|
||||
offline: 0,
|
||||
avgUptime24h: 0,
|
||||
avgUptime7d: 0,
|
||||
avgLatency: 0,
|
||||
};
|
||||
|
||||
if (websites && websites.length > 0) {
|
||||
let totalUptime24h = 0;
|
||||
let totalUptime7d = 0;
|
||||
let totalLatency = 0;
|
||||
let latencyCount = 0;
|
||||
|
||||
websites.forEach(site => {
|
||||
// 检查所有URL的状态
|
||||
const hasOnlineUrl = site.url_statuses?.some(us => us.current_state?.is_up);
|
||||
if (hasOnlineUrl) {
|
||||
stats.online++;
|
||||
} else {
|
||||
stats.offline++;
|
||||
}
|
||||
|
||||
totalUptime24h += site.uptime_24h || 0;
|
||||
totalUptime7d += site.uptime_7d || 0;
|
||||
|
||||
site.url_statuses?.forEach(us => {
|
||||
if (us.current_state?.latency) {
|
||||
totalLatency += us.current_state.latency;
|
||||
latencyCount++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
stats.avgUptime24h = totalUptime24h / stats.total;
|
||||
stats.avgUptime7d = totalUptime7d / stats.total;
|
||||
stats.avgLatency = latencyCount > 0 ? Math.round(totalLatency / latencyCount) : 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{/* 监控网站数 */}
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">监控网站</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-3 text-xs">
|
||||
<span className="text-green-500 flex items-center">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 mr-1"></span>
|
||||
{stats.online} 在线
|
||||
</span>
|
||||
<span className="text-gray-300 mx-2">|</span>
|
||||
<span className="text-red-500 flex items-center">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 mr-1"></span>
|
||||
{stats.offline} 离线
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 24小时可用率 */}
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">24h 可用率</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${getUptimeColor(stats.avgUptime24h)}`}>
|
||||
{formatUptime(stats.avgUptime24h)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-50 to-emerald-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-400 to-green-500 rounded-full"
|
||||
style={{ width: `${Math.min(stats.avgUptime24h, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7天可用率 */}
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">7d 可用率</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${getUptimeColor(stats.avgUptime7d)}`}>
|
||||
{formatUptime(stats.avgUptime7d)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-50 to-green-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-400 to-teal-500 rounded-full"
|
||||
style={{ width: `${Math.min(stats.avgUptime7d, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 平均延迟 */}
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">平均延迟</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">
|
||||
{stats.avgLatency}
|
||||
<span className="text-sm font-normal text-gray-500 ml-1">ms</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-50 to-purple-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-3 text-xs text-gray-500">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
每5分钟检测一次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { formatUptime, getUptimeColor } from '../hooks/useMonitor';
|
||||
|
||||
// 统计概览卡片
|
||||
export default function StatsCard({ websites }) {
|
||||
// 计算统计数据
|
||||
const stats = {
|
||||
total: websites?.length || 0,
|
||||
online: 0,
|
||||
offline: 0,
|
||||
avgUptime24h: 0,
|
||||
avgUptime7d: 0,
|
||||
avgLatency: 0,
|
||||
};
|
||||
|
||||
if (websites && websites.length > 0) {
|
||||
let totalUptime24h = 0;
|
||||
let totalUptime7d = 0;
|
||||
let totalLatency = 0;
|
||||
let latencyCount = 0;
|
||||
|
||||
websites.forEach(site => {
|
||||
// 检查所有URL的状态
|
||||
const hasOnlineUrl = site.url_statuses?.some(us => us.current_state?.is_up);
|
||||
if (hasOnlineUrl) {
|
||||
stats.online++;
|
||||
} else {
|
||||
stats.offline++;
|
||||
}
|
||||
|
||||
totalUptime24h += site.uptime_24h || 0;
|
||||
totalUptime7d += site.uptime_7d || 0;
|
||||
|
||||
site.url_statuses?.forEach(us => {
|
||||
if (us.current_state?.latency) {
|
||||
totalLatency += us.current_state.latency;
|
||||
latencyCount++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
stats.avgUptime24h = totalUptime24h / stats.total;
|
||||
stats.avgUptime7d = totalUptime7d / stats.total;
|
||||
stats.avgLatency = latencyCount > 0 ? Math.round(totalLatency / latencyCount) : 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{/* 监控网站数 */}
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">监控网站</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-3 text-xs">
|
||||
<span className="text-green-500 flex items-center">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 mr-1"></span>
|
||||
{stats.online} 在线
|
||||
</span>
|
||||
<span className="text-gray-300 mx-2">|</span>
|
||||
<span className="text-red-500 flex items-center">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 mr-1"></span>
|
||||
{stats.offline} 离线
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 24小时可用率 */}
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">24h 可用率</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${getUptimeColor(stats.avgUptime24h)}`}>
|
||||
{formatUptime(stats.avgUptime24h)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-50 to-emerald-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-400 to-green-500 rounded-full"
|
||||
style={{ width: `${Math.min(stats.avgUptime24h, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7天可用率 */}
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">7d 可用率</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${getUptimeColor(stats.avgUptime7d)}`}>
|
||||
{formatUptime(stats.avgUptime7d)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-50 to-green-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-400 to-teal-500 rounded-full"
|
||||
style={{ width: `${Math.min(stats.avgUptime7d, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 平均延迟 */}
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">平均延迟</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">
|
||||
{stats.avgLatency}
|
||||
<span className="text-sm font-normal text-gray-500 ml-1">ms</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-50 to-purple-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-3 text-xs text-gray-500">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
每5分钟检测一次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,212 +1,212 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
// 简易折线图组件
|
||||
export default function UptimeChart({ data, height = 120, showLabels = true }) {
|
||||
// 处理数据
|
||||
const chartData = useMemo(() => {
|
||||
if (!data || data.length === 0) {
|
||||
// 生成模拟数据点
|
||||
return Array(24).fill(null).map((_, i) => ({
|
||||
hour: i,
|
||||
uptime: null,
|
||||
avgLatency: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// 按时间排序
|
||||
const sorted = [...data].sort((a, b) =>
|
||||
new Date(a.hour).getTime() - new Date(b.hour).getTime()
|
||||
);
|
||||
|
||||
return sorted.map(item => ({
|
||||
hour: new Date(item.hour).getHours(),
|
||||
uptime: item.uptime,
|
||||
avgLatency: item.avg_latency,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
// 计算图表尺寸
|
||||
const padding = { top: 20, right: 20, bottom: showLabels ? 30 : 10, left: showLabels ? 40 : 10 };
|
||||
const width = 400;
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
// 生成路径
|
||||
const pathData = useMemo(() => {
|
||||
const validPoints = chartData.filter(d => d.uptime !== null);
|
||||
if (validPoints.length === 0) return '';
|
||||
|
||||
const xStep = chartWidth / (chartData.length - 1 || 1);
|
||||
|
||||
let path = '';
|
||||
let lastValidIndex = -1;
|
||||
|
||||
chartData.forEach((d, i) => {
|
||||
if (d.uptime !== null) {
|
||||
const x = i * xStep;
|
||||
const y = chartHeight - (d.uptime / 100 * chartHeight);
|
||||
|
||||
if (lastValidIndex === -1) {
|
||||
path += `M ${x} ${y}`;
|
||||
} else {
|
||||
path += ` L ${x} ${y}`;
|
||||
}
|
||||
lastValidIndex = i;
|
||||
}
|
||||
});
|
||||
|
||||
return path;
|
||||
}, [chartData, chartWidth, chartHeight]);
|
||||
|
||||
// 生成填充区域
|
||||
const areaPath = useMemo(() => {
|
||||
if (!pathData) return '';
|
||||
|
||||
const validPoints = chartData.filter(d => d.uptime !== null);
|
||||
if (validPoints.length === 0) return '';
|
||||
|
||||
const xStep = chartWidth / (chartData.length - 1 || 1);
|
||||
const firstValidIndex = chartData.findIndex(d => d.uptime !== null);
|
||||
const lastValidIndex = chartData.length - 1 - [...chartData].reverse().findIndex(d => d.uptime !== null);
|
||||
|
||||
const startX = firstValidIndex * xStep;
|
||||
const endX = lastValidIndex * xStep;
|
||||
|
||||
return `${pathData} L ${endX} ${chartHeight} L ${startX} ${chartHeight} Z`;
|
||||
}, [pathData, chartData, chartWidth, chartHeight]);
|
||||
|
||||
// 获取颜色
|
||||
const getColor = (uptime) => {
|
||||
if (uptime >= 99) return '#10b981'; // emerald-500
|
||||
if (uptime >= 95) return '#34d399'; // emerald-400
|
||||
if (uptime >= 90) return '#fbbf24'; // amber-400
|
||||
return '#ef4444'; // red-500
|
||||
};
|
||||
|
||||
// 计算平均可用率
|
||||
const avgUptime = useMemo(() => {
|
||||
const validPoints = chartData.filter(d => d.uptime !== null);
|
||||
if (validPoints.length === 0) return null;
|
||||
return validPoints.reduce((sum, d) => sum + d.uptime, 0) / validPoints.length;
|
||||
}, [chartData]);
|
||||
|
||||
const color = avgUptime !== null ? getColor(avgUptime) : '#d1d5db';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-auto"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="uptimeGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(${padding.left}, ${padding.top})`}>
|
||||
{/* 网格线 */}
|
||||
{[0, 25, 50, 75, 100].map(v => (
|
||||
<g key={v}>
|
||||
<line
|
||||
x1={0}
|
||||
y1={chartHeight - (v / 100 * chartHeight)}
|
||||
x2={chartWidth}
|
||||
y2={chartHeight - (v / 100 * chartHeight)}
|
||||
stroke="#e5e7eb"
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
{showLabels && (
|
||||
<text
|
||||
x={-8}
|
||||
y={chartHeight - (v / 100 * chartHeight) + 4}
|
||||
fill="#9ca3af"
|
||||
fontSize="10"
|
||||
textAnchor="end"
|
||||
>
|
||||
{v}%
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* 填充区域 */}
|
||||
{areaPath && (
|
||||
<path
|
||||
d={areaPath}
|
||||
fill="url(#uptimeGradient)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 折线 */}
|
||||
{pathData && (
|
||||
<path
|
||||
d={pathData}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 数据点 */}
|
||||
{chartData.map((d, i) => {
|
||||
if (d.uptime === null) return null;
|
||||
const xStep = chartWidth / (chartData.length - 1 || 1);
|
||||
const x = i * xStep;
|
||||
const y = chartHeight - (d.uptime / 100 * chartHeight);
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="3"
|
||||
fill="white"
|
||||
stroke={getColor(d.uptime)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X轴时间标签 */}
|
||||
{showLabels && chartData.length > 0 && (
|
||||
<>
|
||||
{[0, Math.floor(chartData.length / 2), chartData.length - 1].map(i => {
|
||||
if (i >= chartData.length) return null;
|
||||
const xStep = chartWidth / (chartData.length - 1 || 1);
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={i * xStep}
|
||||
y={chartHeight + 20}
|
||||
fill="#9ca3af"
|
||||
fontSize="10"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{chartData[i]?.hour || 0}:00
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* 无数据提示 */}
|
||||
{!pathData && (
|
||||
<text
|
||||
x={width / 2}
|
||||
y={height / 2}
|
||||
fill="#9ca3af"
|
||||
fontSize="12"
|
||||
textAnchor="middle"
|
||||
>
|
||||
暂无数据
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useMemo } from 'react';
|
||||
|
||||
// 简易折线图组件
|
||||
export default function UptimeChart({ data, height = 120, showLabels = true }) {
|
||||
// 处理数据
|
||||
const chartData = useMemo(() => {
|
||||
if (!data || data.length === 0) {
|
||||
// 生成模拟数据点
|
||||
return Array(24).fill(null).map((_, i) => ({
|
||||
hour: i,
|
||||
uptime: null,
|
||||
avgLatency: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// 按时间排序
|
||||
const sorted = [...data].sort((a, b) =>
|
||||
new Date(a.hour).getTime() - new Date(b.hour).getTime()
|
||||
);
|
||||
|
||||
return sorted.map(item => ({
|
||||
hour: new Date(item.hour).getHours(),
|
||||
uptime: item.uptime,
|
||||
avgLatency: item.avg_latency,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
// 计算图表尺寸
|
||||
const padding = { top: 20, right: 20, bottom: showLabels ? 30 : 10, left: showLabels ? 40 : 10 };
|
||||
const width = 400;
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
// 生成路径
|
||||
const pathData = useMemo(() => {
|
||||
const validPoints = chartData.filter(d => d.uptime !== null);
|
||||
if (validPoints.length === 0) return '';
|
||||
|
||||
const xStep = chartWidth / (chartData.length - 1 || 1);
|
||||
|
||||
let path = '';
|
||||
let lastValidIndex = -1;
|
||||
|
||||
chartData.forEach((d, i) => {
|
||||
if (d.uptime !== null) {
|
||||
const x = i * xStep;
|
||||
const y = chartHeight - (d.uptime / 100 * chartHeight);
|
||||
|
||||
if (lastValidIndex === -1) {
|
||||
path += `M ${x} ${y}`;
|
||||
} else {
|
||||
path += ` L ${x} ${y}`;
|
||||
}
|
||||
lastValidIndex = i;
|
||||
}
|
||||
});
|
||||
|
||||
return path;
|
||||
}, [chartData, chartWidth, chartHeight]);
|
||||
|
||||
// 生成填充区域
|
||||
const areaPath = useMemo(() => {
|
||||
if (!pathData) return '';
|
||||
|
||||
const validPoints = chartData.filter(d => d.uptime !== null);
|
||||
if (validPoints.length === 0) return '';
|
||||
|
||||
const xStep = chartWidth / (chartData.length - 1 || 1);
|
||||
const firstValidIndex = chartData.findIndex(d => d.uptime !== null);
|
||||
const lastValidIndex = chartData.length - 1 - [...chartData].reverse().findIndex(d => d.uptime !== null);
|
||||
|
||||
const startX = firstValidIndex * xStep;
|
||||
const endX = lastValidIndex * xStep;
|
||||
|
||||
return `${pathData} L ${endX} ${chartHeight} L ${startX} ${chartHeight} Z`;
|
||||
}, [pathData, chartData, chartWidth, chartHeight]);
|
||||
|
||||
// 获取颜色
|
||||
const getColor = (uptime) => {
|
||||
if (uptime >= 99) return '#10b981'; // emerald-500
|
||||
if (uptime >= 95) return '#34d399'; // emerald-400
|
||||
if (uptime >= 90) return '#fbbf24'; // amber-400
|
||||
return '#ef4444'; // red-500
|
||||
};
|
||||
|
||||
// 计算平均可用率
|
||||
const avgUptime = useMemo(() => {
|
||||
const validPoints = chartData.filter(d => d.uptime !== null);
|
||||
if (validPoints.length === 0) return null;
|
||||
return validPoints.reduce((sum, d) => sum + d.uptime, 0) / validPoints.length;
|
||||
}, [chartData]);
|
||||
|
||||
const color = avgUptime !== null ? getColor(avgUptime) : '#d1d5db';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-auto"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="uptimeGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(${padding.left}, ${padding.top})`}>
|
||||
{/* 网格线 */}
|
||||
{[0, 25, 50, 75, 100].map(v => (
|
||||
<g key={v}>
|
||||
<line
|
||||
x1={0}
|
||||
y1={chartHeight - (v / 100 * chartHeight)}
|
||||
x2={chartWidth}
|
||||
y2={chartHeight - (v / 100 * chartHeight)}
|
||||
stroke="#e5e7eb"
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
{showLabels && (
|
||||
<text
|
||||
x={-8}
|
||||
y={chartHeight - (v / 100 * chartHeight) + 4}
|
||||
fill="#9ca3af"
|
||||
fontSize="10"
|
||||
textAnchor="end"
|
||||
>
|
||||
{v}%
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* 填充区域 */}
|
||||
{areaPath && (
|
||||
<path
|
||||
d={areaPath}
|
||||
fill="url(#uptimeGradient)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 折线 */}
|
||||
{pathData && (
|
||||
<path
|
||||
d={pathData}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 数据点 */}
|
||||
{chartData.map((d, i) => {
|
||||
if (d.uptime === null) return null;
|
||||
const xStep = chartWidth / (chartData.length - 1 || 1);
|
||||
const x = i * xStep;
|
||||
const y = chartHeight - (d.uptime / 100 * chartHeight);
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="3"
|
||||
fill="white"
|
||||
stroke={getColor(d.uptime)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X轴时间标签 */}
|
||||
{showLabels && chartData.length > 0 && (
|
||||
<>
|
||||
{[0, Math.floor(chartData.length / 2), chartData.length - 1].map(i => {
|
||||
if (i >= chartData.length) return null;
|
||||
const xStep = chartWidth / (chartData.length - 1 || 1);
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={i * xStep}
|
||||
y={chartHeight + 20}
|
||||
fill="#9ca3af"
|
||||
fontSize="10"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{chartData[i]?.hour || 0}:00
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* 无数据提示 */}
|
||||
{!pathData && (
|
||||
<text
|
||||
x={width / 2}
|
||||
y={height / 2}
|
||||
fill="#9ca3af"
|
||||
fontSize="12"
|
||||
textAnchor="middle"
|
||||
>
|
||||
暂无数据
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,207 +1,266 @@
|
||||
import { useState } from 'react';
|
||||
import { formatLatency, formatUptime, formatTime, getStatusColor, getUptimeColor, getLatencyColor } from '../hooks/useMonitor';
|
||||
|
||||
// 网站状态卡片组件
|
||||
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// 获取第一个URL的状态作为主状态
|
||||
const primaryStatus = website.url_statuses?.[0];
|
||||
const isUp = primaryStatus?.current_state?.is_up ?? false;
|
||||
const statusCode = primaryStatus?.current_state?.status_code ?? 0;
|
||||
const latency = primaryStatus?.current_state?.latency ?? 0;
|
||||
const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
|
||||
{/* 卡片头部 */}
|
||||
<div
|
||||
className="p-3 cursor-pointer"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{/* 第一行:图标、名称、状态 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
{/* Favicon */}
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
{website.website?.favicon ? (
|
||||
<img
|
||||
src={website.website.favicon}
|
||||
alt=""
|
||||
className="w-7 h-7 object-contain drop-shadow"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={`text-base font-bold text-emerald-600 ${website.website?.favicon ? 'hidden' : ''}`}
|
||||
style={{ display: website.website?.favicon ? 'none' : 'flex' }}
|
||||
>
|
||||
{website.website?.name?.[0] || '?'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 网站名称 */}
|
||||
<h3 className="font-semibold text-gray-800 truncate">
|
||||
{website.website?.name || '未知网站'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 展开箭头 */}
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transform transition-transform flex-shrink-0 ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 第二行:状态、延迟、访问按钮 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 状态徽章 */}
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(isUp, statusCode)}`}>
|
||||
{isUp ? `${statusCode}` : '离线'}
|
||||
</span>
|
||||
|
||||
{/* 延迟 */}
|
||||
<span className={`text-xs font-medium ${getLatencyColor(latency)}`}>
|
||||
{formatLatency(latency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 访问按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (primaryUrl) {
|
||||
window.open(primaryUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
disabled={!primaryUrl}
|
||||
className={`flex items-center space-x-1 px-2.5 py-1 text-xs font-medium rounded-full transition-all flex-shrink-0 ${
|
||||
primaryUrl
|
||||
? 'text-white bg-gradient-to-r from-emerald-500 to-green-500 hover:from-emerald-600 hover:to-green-600 shadow-sm hover:shadow'
|
||||
: 'text-gray-400 bg-gray-100 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
<span>访问</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 网站描述 */}
|
||||
<p className="text-xs text-gray-500 truncate mb-2">
|
||||
{website.website?.title || website.website?.urls?.[0]?.url || '-'}
|
||||
</p>
|
||||
|
||||
{/* 可用率条 */}
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>24h可用率</span>
|
||||
<span className={getUptimeColor(website.uptime_24h || 0)}>
|
||||
{formatUptime(website.uptime_24h)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-400 to-green-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(website.uptime_24h || 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开详情 */}
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-100 bg-gray-50 p-4">
|
||||
{/* URL列表 */}
|
||||
<div className="space-y-3">
|
||||
{website.url_statuses?.map((urlStatus, index) => (
|
||||
<div
|
||||
key={urlStatus.url_info?.id || index}
|
||||
className="bg-white rounded-lg p-3 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-600 truncate flex-1 mr-2">
|
||||
{urlStatus.url_info?.url}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
getStatusColor(urlStatus.current_state?.is_up, urlStatus.current_state?.status_code)
|
||||
}`}>
|
||||
{urlStatus.current_state?.is_up ? urlStatus.current_state?.status_code : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-400">延迟</span>
|
||||
<p className={`font-medium ${getLatencyColor(urlStatus.current_state?.latency)}`}>
|
||||
{formatLatency(urlStatus.current_state?.latency)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">24h</span>
|
||||
<p className={`font-medium ${getUptimeColor(urlStatus.uptime_24h)}`}>
|
||||
{formatUptime(urlStatus.uptime_24h)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">7d</span>
|
||||
<p className={`font-medium ${getUptimeColor(urlStatus.uptime_7d)}`}>
|
||||
{formatUptime(urlStatus.uptime_7d)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRefresh?.(website.website?.id);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium text-emerald-600 bg-emerald-50 rounded-lg hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
立即检测
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(website);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('确定要删除这个网站吗?')) {
|
||||
onDelete?.(website.website?.id);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 最后检测时间 */}
|
||||
<div className="text-xs text-gray-400 text-right mt-2">
|
||||
最后检测: {formatTime(website.last_checked)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useMemo } from 'react';
|
||||
import { formatLatency, formatUptime, formatTime, getStatusColor, getUptimeColor, getLatencyColor } from '../hooks/useMonitor';
|
||||
|
||||
const HISTORY_DAYS = 90;
|
||||
|
||||
function getBarColor(bar) {
|
||||
if (bar.uptime === null) return 'bg-gray-200';
|
||||
if (bar.uptime >= 99) return 'bg-emerald-400';
|
||||
if (bar.uptime >= 95) return 'bg-yellow-400';
|
||||
if (bar.uptime >= 80) return 'bg-orange-400';
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
function getBarHoverColor(bar) {
|
||||
if (bar.uptime === null) return 'hover:bg-gray-300';
|
||||
if (bar.uptime >= 99) return 'hover:bg-emerald-500';
|
||||
if (bar.uptime >= 95) return 'hover:bg-yellow-500';
|
||||
if (bar.uptime >= 80) return 'hover:bg-orange-500';
|
||||
return 'hover:bg-red-600';
|
||||
}
|
||||
|
||||
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [hoveredBar, setHoveredBar] = useState(null);
|
||||
|
||||
const primaryStatus = website.url_statuses?.[0];
|
||||
const isUp = primaryStatus?.current_state?.is_up ?? false;
|
||||
const statusCode = primaryStatus?.current_state?.status_code ?? 0;
|
||||
const latency = primaryStatus?.current_state?.latency ?? 0;
|
||||
const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
|
||||
|
||||
const dailyBars = useMemo(() => {
|
||||
const bars = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const statsMap = {};
|
||||
(website.daily_history || []).forEach(stat => {
|
||||
const d = new Date(stat.date);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
statsMap[key] = stat;
|
||||
});
|
||||
|
||||
for (let i = HISTORY_DAYS - 1; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const stat = statsMap[key];
|
||||
bars.push({
|
||||
date: key,
|
||||
uptime: stat ? stat.uptime : null,
|
||||
totalCount: stat?.total_count ?? 0,
|
||||
avgLatency: stat?.avg_latency ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return bars;
|
||||
}, [website.daily_history]);
|
||||
|
||||
const statusDisplay = (() => {
|
||||
if (!primaryStatus?.current_state?.checked_at || primaryStatus.current_state.checked_at === '0001-01-01T00:00:00Z') {
|
||||
return { text: '等待检测', color: 'text-gray-400' };
|
||||
}
|
||||
if (isUp) {
|
||||
return { text: '运行正常', color: 'text-emerald-500' };
|
||||
}
|
||||
return { text: '服务异常', color: 'text-red-500' };
|
||||
})();
|
||||
|
||||
const uptime90d = website.uptime_90d;
|
||||
const hasUptimeData = uptime90d != null && uptime90d > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
|
||||
{/* 主体区域 */}
|
||||
<div
|
||||
className="p-3 sm:p-4 cursor-pointer select-none"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{/* 头部:图标 + 名称 + 状态 + 访问按钮 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
{primaryUrl ? (
|
||||
<img
|
||||
src={`https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(primaryUrl)}`}
|
||||
alt=""
|
||||
className="w-7 h-7 object-contain drop-shadow"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={`text-base font-bold text-emerald-600 ${primaryUrl ? 'hidden' : ''}`}
|
||||
style={{ display: primaryUrl ? 'none' : 'flex' }}
|
||||
>
|
||||
{website.website?.name?.[0] || '?'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-gray-800 truncate">
|
||||
{website.website?.name || '未知网站'}
|
||||
</h3>
|
||||
<span className={`text-xs font-medium ${statusDisplay.color}`}>
|
||||
{statusDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{primaryUrl && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(primaryUrl, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
className="flex items-center space-x-1 px-2.5 py-1 text-xs font-medium text-white bg-gradient-to-r from-emerald-500 to-green-500 rounded-full hover:from-emerald-600 hover:to-green-600 transition-all shadow-sm hover:shadow flex-shrink-0 ml-2"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
<span>访问</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 90 天可用率竖条图 */}
|
||||
<div className="relative">
|
||||
<div className="flex items-stretch gap-[1px] h-[26px]">
|
||||
{dailyBars.map((bar, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-[1px] transition-colors ${getBarColor(bar)} ${getBarHoverColor(bar)}`}
|
||||
onMouseEnter={() => setHoveredBar(i)}
|
||||
onMouseLeave={() => setHoveredBar(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 悬浮提示 */}
|
||||
{hoveredBar !== null && dailyBars[hoveredBar] && (
|
||||
<div
|
||||
className="absolute -top-10 px-2 py-1 bg-gray-800 text-white text-[10px] rounded shadow-lg whitespace-nowrap pointer-events-none z-10"
|
||||
style={{
|
||||
left: `${(hoveredBar / HISTORY_DAYS) * 100}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
{dailyBars[hoveredBar].date}
|
||||
{' · '}
|
||||
{dailyBars[hoveredBar].uptime !== null
|
||||
? `${dailyBars[hoveredBar].uptime.toFixed(1)}%`
|
||||
: '无数据'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部标签 */}
|
||||
<div className="flex justify-between items-center text-[10px] text-gray-400 mt-1.5">
|
||||
<span>{HISTORY_DAYS} 天前</span>
|
||||
<span className="font-medium text-gray-500">
|
||||
{hasUptimeData ? `${uptime90d.toFixed(2)}% 可用率` : '-'}
|
||||
</span>
|
||||
<span>今天</span>
|
||||
</div>
|
||||
|
||||
{/* 状态 / 延迟 / 24h */}
|
||||
<div className="flex items-center gap-3 mt-2 text-[10px]">
|
||||
<span className="text-gray-400">状态 <span className={`font-semibold ${getStatusColor(isUp, statusCode)}`}>{isUp ? statusCode : '离线'}</span></span>
|
||||
<span className="text-gray-400">延迟 <span className={`font-semibold ${getLatencyColor(latency)}`}>{formatLatency(latency)}</span></span>
|
||||
<span className="text-gray-400">24h <span className={`font-semibold ${getUptimeColor(website.uptime_24h || 0)}`}>{formatUptime(website.uptime_24h)}</span></span>
|
||||
</div>
|
||||
|
||||
{/* IP 地址(IPv4 / IPv6 分开显示) */}
|
||||
{website.website?.ip_addresses?.length > 0 && (() => {
|
||||
const ipv4 = website.website.ip_addresses.filter(ip => !ip.includes(':'));
|
||||
const ipv6 = website.website.ip_addresses.filter(ip => ip.includes(':'));
|
||||
return (ipv4.length > 0 || ipv6.length > 0) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{ipv4.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] text-gray-400 w-7 flex-shrink-0">IPv4</span>
|
||||
{ipv4.map((ip, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 bg-gray-50 text-gray-600 rounded text-[10px] font-mono">{ip}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ipv6.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] text-gray-400 w-7 flex-shrink-0">IPv6</span>
|
||||
{ipv6.map((ip, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 bg-gray-50 text-gray-600 rounded text-[10px] font-mono truncate max-w-full">{ip}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 展开详情 */}
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/80 p-3 sm:p-4">
|
||||
{/* URL 列表 */}
|
||||
<div className="space-y-2">
|
||||
{website.url_statuses?.map((urlStatus, index) => (
|
||||
<div
|
||||
key={urlStatus.url_info?.id || index}
|
||||
className="bg-white rounded-lg p-2.5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-600 truncate flex-1 mr-2">
|
||||
{urlStatus.url_info?.url}
|
||||
</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
getStatusColor(urlStatus.current_state?.is_up, urlStatus.current_state?.status_code)
|
||||
}`}>
|
||||
{urlStatus.current_state?.is_up ? urlStatus.current_state?.status_code : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{formatTime(website.last_checked)}
|
||||
</span>
|
||||
<div className="flex space-x-1.5">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRefresh?.(website.website?.id);
|
||||
}}
|
||||
className="px-2.5 py-1 text-[10px] font-medium text-emerald-600 bg-emerald-50 rounded-md hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
检测
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(website);
|
||||
}}
|
||||
className="px-2.5 py-1 text-[10px] font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('确定要删除这个网站吗?')) {
|
||||
onDelete?.(website.website?.id);
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 text-[10px] font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,223 +1,254 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getGroups, createWebsite, updateWebsite } from '../services/api';
|
||||
|
||||
export default function WebsiteModal({ isOpen, onClose, onSuccess, editData }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
group: 'normal',
|
||||
urls: [''],
|
||||
});
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadGroups();
|
||||
if (editData) {
|
||||
setFormData({
|
||||
name: editData.website?.name || '',
|
||||
group: editData.website?.group || 'normal',
|
||||
urls: editData.website?.urls?.map(u => u.url) || [''],
|
||||
});
|
||||
} else {
|
||||
setFormData({ name: '', group: 'normal', urls: [''] });
|
||||
}
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen, editData]);
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
const data = await getGroups();
|
||||
setGroups(data || []);
|
||||
} catch (err) {
|
||||
console.error('加载分组失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUrl = () => {
|
||||
setFormData({ ...formData, urls: [...formData.urls, ''] });
|
||||
};
|
||||
|
||||
const handleRemoveUrl = (index) => {
|
||||
if (formData.urls.length > 1) {
|
||||
const newUrls = formData.urls.filter((_, i) => i !== index);
|
||||
setFormData({ ...formData, urls: newUrls });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (index, value) => {
|
||||
const newUrls = [...formData.urls];
|
||||
newUrls[index] = value;
|
||||
setFormData({ ...formData, urls: newUrls });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// 验证
|
||||
if (!formData.name.trim()) {
|
||||
setError('请输入网站名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const validUrls = formData.urls.filter(url => url.trim());
|
||||
if (validUrls.length === 0) {
|
||||
setError('请至少输入一个网站地址');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
for (const url of validUrls) {
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
setError(`无效的URL: ${url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = {
|
||||
name: formData.name.trim(),
|
||||
group: formData.group,
|
||||
urls: validUrls,
|
||||
};
|
||||
|
||||
if (editData) {
|
||||
await updateWebsite(editData.website.id, data);
|
||||
} else {
|
||||
await createWebsite(data);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden">
|
||||
{/* 标题 */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-emerald-50 to-green-50">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
{editData ? '编辑网站' : '添加监控网站'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-100 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 网站名称 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
网站名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="例如:我的博客"
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 所属分组 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
所属分组
|
||||
</label>
|
||||
<select
|
||||
value={formData.group}
|
||||
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all bg-white"
|
||||
>
|
||||
{groups.map(group => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 网站地址列表 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
网站地址 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
一个网站可以有多个访问地址,将分别监控
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{formData.urls.map((url, index) => (
|
||||
<div key={index} className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => handleUrlChange(index, e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
{formData.urls.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveUrl(index)}
|
||||
className="px-3 py-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddUrl}
|
||||
className="mt-2 text-sm text-emerald-600 hover:text-emerald-700 flex items-center"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
添加更多地址
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-emerald-500 to-green-500 rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all disabled:opacity-50"
|
||||
>
|
||||
{loading ? '处理中...' : (editData ? '保存' : '添加')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getGroups, createWebsite, updateWebsite } from '../services/api';
|
||||
|
||||
export default function WebsiteModal({ isOpen, onClose, onSuccess, editData }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
groups: [],
|
||||
urls: [''],
|
||||
});
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadGroups();
|
||||
if (editData) {
|
||||
const editGroups = editData.website?.groups
|
||||
|| (editData.website?.group ? [editData.website.group] : []);
|
||||
setFormData({
|
||||
name: editData.website?.name || '',
|
||||
groups: editGroups,
|
||||
urls: editData.website?.urls?.map(u => u.url) || [''],
|
||||
});
|
||||
} else {
|
||||
setFormData({ name: '', groups: [], urls: [''] });
|
||||
}
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen, editData]);
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
const data = await getGroups();
|
||||
setGroups(data || []);
|
||||
} catch (err) {
|
||||
console.error('加载分组失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUrl = () => {
|
||||
setFormData({ ...formData, urls: [...formData.urls, ''] });
|
||||
};
|
||||
|
||||
const handleRemoveUrl = (index) => {
|
||||
if (formData.urls.length > 1) {
|
||||
const newUrls = formData.urls.filter((_, i) => i !== index);
|
||||
setFormData({ ...formData, urls: newUrls });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (index, value) => {
|
||||
const newUrls = [...formData.urls];
|
||||
newUrls[index] = value;
|
||||
setFormData({ ...formData, urls: newUrls });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// 验证
|
||||
if (!formData.name.trim()) {
|
||||
setError('请输入网站名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const validUrls = formData.urls.filter(url => url.trim());
|
||||
if (validUrls.length === 0) {
|
||||
setError('请至少输入一个网站地址');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
for (const url of validUrls) {
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
setError(`无效的URL: ${url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.groups.length === 0) {
|
||||
setError('请至少选择一个分组');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = {
|
||||
name: formData.name.trim(),
|
||||
groups: formData.groups,
|
||||
urls: validUrls,
|
||||
};
|
||||
|
||||
if (editData) {
|
||||
await updateWebsite(editData.website.id, data);
|
||||
} else {
|
||||
await createWebsite(data);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden">
|
||||
{/* 标题 */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-emerald-50 to-green-50">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
{editData ? '编辑网站' : '添加监控网站'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-100 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 网站名称 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
网站名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="例如:我的博客"
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 所属分组(多选) */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
所属分组 <span className="text-xs text-gray-400 font-normal">(可多选)</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 p-3 border border-gray-200 rounded-lg bg-white">
|
||||
{groups.map(group => {
|
||||
const checked = formData.groups.includes(group.id);
|
||||
return (
|
||||
<label
|
||||
key={group.id}
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm cursor-pointer transition-all select-none ${
|
||||
checked
|
||||
? 'bg-emerald-100 text-emerald-700 ring-1 ring-emerald-300'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const next = checked
|
||||
? formData.groups.filter(id => id !== group.id)
|
||||
: [...formData.groups, group.id];
|
||||
setFormData({ ...formData, groups: next });
|
||||
}}
|
||||
/>
|
||||
{checked && (
|
||||
<svg className="w-3.5 h-3.5 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
{group.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 网站地址列表 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
网站地址 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
一个网站可以有多个访问地址,将分别监控
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{formData.urls.map((url, index) => (
|
||||
<div key={index} className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => handleUrlChange(index, e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
{formData.urls.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveUrl(index)}
|
||||
className="px-3 py-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddUrl}
|
||||
className="mt-2 text-sm text-emerald-600 hover:text-emerald-700 flex items-center"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
添加更多地址
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-emerald-500 to-green-500 rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all disabled:opacity-50"
|
||||
>
|
||||
{loading ? '处理中...' : (editData ? '保存' : '添加')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +1,83 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// 自动刷新数据Hook
|
||||
export function useAutoRefresh(fetchFn, interval = 30000) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await fetchFn();
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchFn]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
|
||||
const timer = setInterval(refresh, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh, interval]);
|
||||
|
||||
return { data, loading, error, refresh };
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
export function formatTime(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化延迟
|
||||
export function formatLatency(ms) {
|
||||
if (ms === undefined || ms === null) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
// 格式化可用率
|
||||
export function formatUptime(uptime) {
|
||||
if (uptime === undefined || uptime === null) return '-';
|
||||
return `${uptime.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
// 获取状态颜色类名
|
||||
export function getStatusColor(isUp, statusCode) {
|
||||
if (!isUp) return 'text-red-500 bg-red-100';
|
||||
if (statusCode >= 200 && statusCode < 300) return 'text-green-500 bg-green-100';
|
||||
if (statusCode >= 300 && statusCode < 400) return 'text-yellow-500 bg-yellow-100';
|
||||
return 'text-red-500 bg-red-100';
|
||||
}
|
||||
|
||||
// 获取可用率颜色
|
||||
export function getUptimeColor(uptime) {
|
||||
if (uptime >= 99) return 'text-green-500';
|
||||
if (uptime >= 95) return 'text-green-400';
|
||||
if (uptime >= 90) return 'text-yellow-500';
|
||||
if (uptime >= 80) return 'text-orange-500';
|
||||
return 'text-red-500';
|
||||
}
|
||||
|
||||
// 获取延迟颜色
|
||||
export function getLatencyColor(ms) {
|
||||
if (ms < 200) return 'text-green-500';
|
||||
if (ms < 500) return 'text-green-400';
|
||||
if (ms < 1000) return 'text-yellow-500';
|
||||
if (ms < 2000) return 'text-orange-500';
|
||||
return 'text-red-500';
|
||||
}
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// 自动刷新数据Hook
|
||||
export function useAutoRefresh(fetchFn, interval = 30000) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await fetchFn();
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchFn]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
|
||||
const timer = setInterval(refresh, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh, interval]);
|
||||
|
||||
return { data, loading, error, refresh };
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
export function formatTime(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化延迟
|
||||
export function formatLatency(ms) {
|
||||
if (ms === undefined || ms === null) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
// 格式化可用率
|
||||
export function formatUptime(uptime) {
|
||||
if (uptime === undefined || uptime === null) return '-';
|
||||
return `${uptime.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
// 获取状态颜色类名
|
||||
export function getStatusColor(isUp, statusCode) {
|
||||
if (!isUp) return 'text-red-500 bg-red-100';
|
||||
if (statusCode >= 200 && statusCode < 300) return 'text-green-500 bg-green-100';
|
||||
if (statusCode >= 300 && statusCode < 400) return 'text-yellow-500 bg-yellow-100';
|
||||
return 'text-red-500 bg-red-100';
|
||||
}
|
||||
|
||||
// 获取可用率颜色
|
||||
export function getUptimeColor(uptime) {
|
||||
if (uptime >= 99) return 'text-green-500';
|
||||
if (uptime >= 95) return 'text-green-400';
|
||||
if (uptime >= 90) return 'text-yellow-500';
|
||||
if (uptime >= 80) return 'text-orange-500';
|
||||
return 'text-red-500';
|
||||
}
|
||||
|
||||
// 获取延迟颜色
|
||||
export function getLatencyColor(ms) {
|
||||
if (ms < 200) return 'text-green-500';
|
||||
if (ms < 500) return 'text-green-400';
|
||||
if (ms < 1000) return 'text-yellow-500';
|
||||
if (ms < 2000) return 'text-orange-500';
|
||||
return 'text-red-500';
|
||||
}
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light;
|
||||
color: #374151;
|
||||
background-color: #f0fdf4;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #10b981;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 640px) {
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light;
|
||||
color: #374151;
|
||||
background-color: #f0fdf4;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #10b981;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 640px) {
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,14 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch((err) => {
|
||||
console.error('Service worker registration failed:', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -1,274 +1,280 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import WebsiteCard from '../components/WebsiteCard';
|
||||
import WebsiteModal from '../components/WebsiteModal';
|
||||
import StatsCard from '../components/StatsCard';
|
||||
import { useAutoRefresh } from '../hooks/useMonitor';
|
||||
import { getWebsites, deleteWebsite, checkWebsiteNow, getGroups } from '../services/api';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editData, setEditData] = useState(null);
|
||||
const [selectedGroup, setSelectedGroup] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 获取网站数据
|
||||
const fetchData = useCallback(() => getWebsites(), []);
|
||||
const { data: websites, loading, error, refresh } = useAutoRefresh(fetchData, 30000);
|
||||
|
||||
// 获取分组数据
|
||||
const fetchGroups = useCallback(() => getGroups(), []);
|
||||
const { data: groups } = useAutoRefresh(fetchGroups, 60000);
|
||||
|
||||
// 处理添加网站
|
||||
const handleAdd = () => {
|
||||
setEditData(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 处理编辑网站
|
||||
const handleEdit = (website) => {
|
||||
setEditData(website);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
|
||||
// 处理删除网站
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteWebsite(id);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
alert('删除失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理立即检测
|
||||
const handleRefresh = async (id) => {
|
||||
try {
|
||||
await checkWebsiteNow(id);
|
||||
setTimeout(refresh, 2000); // 2秒后刷新数据
|
||||
} catch (err) {
|
||||
alert('检测失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 按分组和搜索过滤网站
|
||||
const filteredWebsites = useMemo(() => {
|
||||
if (!websites) return [];
|
||||
|
||||
return websites.filter(site => {
|
||||
// 分组过滤
|
||||
if (selectedGroup !== 'all' && site.website?.group !== selectedGroup) {
|
||||
return false;
|
||||
}
|
||||
// 搜索过滤
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
site.website?.name?.toLowerCase().includes(term) ||
|
||||
site.website?.title?.toLowerCase().includes(term) ||
|
||||
site.website?.urls?.some(u => u.url.toLowerCase().includes(term))
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [websites, selectedGroup, searchTerm]);
|
||||
|
||||
// 按分组分类网站
|
||||
const groupedWebsites = useMemo(() => {
|
||||
const grouped = {};
|
||||
|
||||
filteredWebsites.forEach(site => {
|
||||
const groupId = site.website?.group || 'normal';
|
||||
if (!grouped[groupId]) {
|
||||
grouped[groupId] = [];
|
||||
}
|
||||
grouped[groupId].push(site);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}, [filteredWebsites]);
|
||||
|
||||
// 获取分组名称
|
||||
const getGroupName = (groupId) => {
|
||||
const group = groups?.find(g => g.id === groupId);
|
||||
return group?.name || groupId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-green-50 to-teal-50">
|
||||
{/* 顶部导航 */}
|
||||
<header className="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-40">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl overflow-hidden shadow-lg shadow-emerald-200">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="萌芽Ping"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-green-600 bg-clip-text text-transparent">
|
||||
萌芽Ping
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">网站监控面板</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加按钮 */}
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="flex items-center px-4 py-2 bg-gradient-to-r from-emerald-500 to-green-500 text-white rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all shadow-md shadow-emerald-200"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">添加监控</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* 统计概览 */}
|
||||
<section className="mb-6">
|
||||
<StatsCard websites={websites} />
|
||||
</section>
|
||||
|
||||
{/* 过滤和搜索 */}
|
||||
<section className="mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* 分组选择 */}
|
||||
<div className="flex items-center space-x-2 overflow-x-auto pb-2 sm:pb-0">
|
||||
<button
|
||||
onClick={() => setSelectedGroup('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${
|
||||
selectedGroup === 'all'
|
||||
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{groups?.map(group => (
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => setSelectedGroup(group.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${
|
||||
selectedGroup === group.id
|
||||
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="flex-1 sm:max-w-xs">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索网站..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 网站列表 */}
|
||||
<section>
|
||||
{loading && !websites ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-500"></div>
|
||||
<span className="ml-3 text-gray-500">加载中...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 mb-4">加载失败: {error}</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : filteredWebsites.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 mb-4">
|
||||
{searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
|
||||
>
|
||||
添加第一个网站
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.keys(groupedWebsites).map(groupId => (
|
||||
<div key={groupId}>
|
||||
{selectedGroup === 'all' && (
|
||||
<h2 className="text-sm font-medium text-gray-600 mb-3 flex items-center">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 mr-2"></span>
|
||||
{getGroupName(groupId)}
|
||||
<span className="ml-2 text-gray-400">({groupedWebsites[groupId].length})</span>
|
||||
</h2>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
|
||||
{groupedWebsites[groupId].map(website => (
|
||||
<WebsiteCard
|
||||
key={website.website?.id}
|
||||
website={website}
|
||||
onRefresh={handleRefresh}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<footer className="py-6 text-center text-sm text-gray-500">
|
||||
<p>萌芽Ping © 2026 </p>
|
||||
</footer>
|
||||
|
||||
{/* 添加/编辑弹窗 */}
|
||||
<WebsiteModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={refresh}
|
||||
editData={editData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import WebsiteCard from '../components/WebsiteCard';
|
||||
import WebsiteModal from '../components/WebsiteModal';
|
||||
import StatsCard from '../components/StatsCard';
|
||||
import { useAutoRefresh } from '../hooks/useMonitor';
|
||||
import { getWebsites, deleteWebsite, checkWebsiteNow, getGroups } from '../services/api';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editData, setEditData] = useState(null);
|
||||
const [selectedGroup, setSelectedGroup] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 获取网站数据
|
||||
const fetchData = useCallback(() => getWebsites(), []);
|
||||
const { data: websites, loading, error, refresh } = useAutoRefresh(fetchData, 30000);
|
||||
|
||||
// 获取分组数据
|
||||
const fetchGroups = useCallback(() => getGroups(), []);
|
||||
const { data: groups } = useAutoRefresh(fetchGroups, 60000);
|
||||
|
||||
// 处理添加网站
|
||||
const handleAdd = () => {
|
||||
setEditData(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 处理编辑网站
|
||||
const handleEdit = (website) => {
|
||||
setEditData(website);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
|
||||
// 处理删除网站
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteWebsite(id);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
alert('删除失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理立即检测
|
||||
const handleRefresh = async (id) => {
|
||||
try {
|
||||
await checkWebsiteNow(id);
|
||||
setTimeout(refresh, 2000); // 2秒后刷新数据
|
||||
} catch (err) {
|
||||
alert('检测失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 按分组和搜索过滤网站
|
||||
const filteredWebsites = useMemo(() => {
|
||||
if (!websites) return [];
|
||||
|
||||
return websites.filter(site => {
|
||||
// 分组过滤(支持多分组)
|
||||
if (selectedGroup !== 'all') {
|
||||
const siteGroups = site.website?.groups || (site.website?.group ? [site.website.group] : []);
|
||||
if (!siteGroups.includes(selectedGroup)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 搜索过滤
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
site.website?.name?.toLowerCase().includes(term) ||
|
||||
site.website?.title?.toLowerCase().includes(term) ||
|
||||
site.website?.urls?.some(u => u.url.toLowerCase().includes(term))
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [websites, selectedGroup, searchTerm]);
|
||||
|
||||
// 按分组分类网站(一个网站可出现在多个分组下)
|
||||
const groupedWebsites = useMemo(() => {
|
||||
const grouped = {};
|
||||
|
||||
filteredWebsites.forEach(site => {
|
||||
const siteGroups = site.website?.groups || (site.website?.group ? [site.website.group] : ['normal']);
|
||||
siteGroups.forEach(groupId => {
|
||||
if (selectedGroup !== 'all' && groupId !== selectedGroup) return;
|
||||
if (!grouped[groupId]) {
|
||||
grouped[groupId] = [];
|
||||
}
|
||||
grouped[groupId].push(site);
|
||||
});
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}, [filteredWebsites, selectedGroup]);
|
||||
|
||||
// 获取分组名称
|
||||
const getGroupName = (groupId) => {
|
||||
const group = groups?.find(g => g.id === groupId);
|
||||
return group?.name || groupId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-green-50 to-teal-50">
|
||||
{/* 顶部导航 */}
|
||||
<header className="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-40">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl overflow-hidden shadow-lg shadow-emerald-200">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="萌芽Ping"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-green-600 bg-clip-text text-transparent">
|
||||
萌芽Ping
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">网站监控面板</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加按钮 */}
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="flex items-center px-4 py-2 bg-gradient-to-r from-emerald-500 to-green-500 text-white rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all shadow-md shadow-emerald-200"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">添加监控</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* 统计概览 */}
|
||||
<section className="mb-6">
|
||||
<StatsCard websites={websites} />
|
||||
</section>
|
||||
|
||||
{/* 过滤和搜索 */}
|
||||
<section className="mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* 分组选择 */}
|
||||
<div className="flex items-center space-x-2 overflow-x-auto pb-2 sm:pb-0">
|
||||
<button
|
||||
onClick={() => setSelectedGroup('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${
|
||||
selectedGroup === 'all'
|
||||
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{groups?.map(group => (
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => setSelectedGroup(group.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${
|
||||
selectedGroup === group.id
|
||||
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="flex-1 sm:max-w-xs">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索网站..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 网站列表 */}
|
||||
<section>
|
||||
{loading && !websites ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-500"></div>
|
||||
<span className="ml-3 text-gray-500">加载中...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 mb-4">加载失败: {error}</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : filteredWebsites.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 mb-4">
|
||||
{searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
|
||||
>
|
||||
添加第一个网站
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.keys(groupedWebsites).map(groupId => (
|
||||
<div key={groupId}>
|
||||
{selectedGroup === 'all' && (
|
||||
<h2 className="text-sm font-medium text-gray-600 mb-3 flex items-center">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 mr-2"></span>
|
||||
{getGroupName(groupId)}
|
||||
<span className="ml-2 text-gray-400">({groupedWebsites[groupId].length})</span>
|
||||
</h2>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
|
||||
{groupedWebsites[groupId].map(website => (
|
||||
<WebsiteCard
|
||||
key={website.website?.id}
|
||||
website={website}
|
||||
onRefresh={handleRefresh}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<footer className="py-6 text-center text-sm text-gray-500">
|
||||
<p>萌芽Ping © 2026 </p>
|
||||
</footer>
|
||||
|
||||
{/* 添加/编辑弹窗 */}
|
||||
<WebsiteModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={refresh}
|
||||
editData={editData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
// API服务
|
||||
// 根据环境变量判断使用哪个 API 地址
|
||||
const API_BASE = import.meta.env.PROD
|
||||
? 'https://ping.api.shumengya.top/api' // 生产环境
|
||||
: 'http://localhost:8080/api'; // 开发环境
|
||||
|
||||
// 通用请求方法
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(data.message || '请求失败');
|
||||
}
|
||||
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// 获取所有网站状态
|
||||
export async function getWebsites() {
|
||||
return request('/websites');
|
||||
}
|
||||
|
||||
// 获取单个网站状态
|
||||
export async function getWebsite(id) {
|
||||
return request(`/websites/${id}`);
|
||||
}
|
||||
|
||||
// 创建网站
|
||||
export async function createWebsite(data) {
|
||||
return request('/websites', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// 更新网站
|
||||
export async function updateWebsite(id, data) {
|
||||
return request(`/websites/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// 删除网站
|
||||
export async function deleteWebsite(id) {
|
||||
return request(`/websites/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// 立即检测网站
|
||||
export async function checkWebsiteNow(id) {
|
||||
return request(`/websites/${id}/check`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有分组
|
||||
export async function getGroups() {
|
||||
return request('/groups');
|
||||
}
|
||||
|
||||
// 添加分组
|
||||
export async function addGroup(name) {
|
||||
return request('/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
// API服务
|
||||
// 根据环境变量判断使用哪个 API 地址
|
||||
const API_BASE = import.meta.env.PROD
|
||||
? 'https://ping.api.shumengya.top/api' // 生产环境
|
||||
: 'http://localhost:8080/api'; // 开发环境
|
||||
|
||||
// 通用请求方法
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(data.message || '请求失败');
|
||||
}
|
||||
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// 获取所有网站状态
|
||||
export async function getWebsites() {
|
||||
return request('/websites');
|
||||
}
|
||||
|
||||
// 获取单个网站状态
|
||||
export async function getWebsite(id) {
|
||||
return request(`/websites/${id}`);
|
||||
}
|
||||
|
||||
// 创建网站
|
||||
export async function createWebsite(data) {
|
||||
return request('/websites', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// 更新网站
|
||||
export async function updateWebsite(id, data) {
|
||||
return request(`/websites/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// 删除网站
|
||||
export async function deleteWebsite(id) {
|
||||
return request(`/websites/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// 立即检测网站
|
||||
export async function checkWebsiteNow(id) {
|
||||
return request(`/websites/${id}/check`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有分组
|
||||
export async function getGroups() {
|
||||
return request('/groups');
|
||||
}
|
||||
|
||||
// 添加分组
|
||||
export async function addGroup(name) {
|
||||
return request('/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -5,4 +5,14 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
|
||||
18
开启前端.bat
18
开启前端.bat
@@ -1,9 +1,9 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ====================================
|
||||
echo 🌱 萌芽Ping 前端服务 (REACT)
|
||||
echo ====================================
|
||||
cd mengyaping-frontend
|
||||
echo 启动开发服务器...
|
||||
npm run dev
|
||||
pause
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ====================================
|
||||
echo 🌱 萌芽Ping 前端服务 (REACT)
|
||||
echo ====================================
|
||||
cd mengyaping-frontend
|
||||
echo 启动开发服务器...
|
||||
npm run dev
|
||||
pause
|
||||
|
||||
22
开启后端.bat
22
开启后端.bat
@@ -1,11 +1,11 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ====================================
|
||||
echo 🌱 萌芽Ping 后端服务 (GIN)
|
||||
echo ====================================
|
||||
cd mengyaping-backend
|
||||
echo 正在下载依赖...
|
||||
go mod tidy
|
||||
echo 启动服务...
|
||||
go run main.go
|
||||
pause
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ====================================
|
||||
echo 🌱 萌芽Ping 后端服务 (GIN)
|
||||
echo ====================================
|
||||
cd mengyaping-backend
|
||||
echo 正在下载依赖...
|
||||
go mod tidy
|
||||
echo 启动服务...
|
||||
go run main.go
|
||||
pause
|
||||
|
||||
20
构建前端.bat
20
构建前端.bat
@@ -1,10 +1,10 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ====================================
|
||||
echo 构建前端项目 (REACT)
|
||||
echo ====================================
|
||||
cd mengyaping-frontend
|
||||
npm run build
|
||||
echo.
|
||||
echo ✅ 构建完成!输出目录: dist
|
||||
pause
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ====================================
|
||||
echo 构建前端项目 (REACT)
|
||||
echo ====================================
|
||||
cd mengyaping-frontend
|
||||
npm run build
|
||||
echo.
|
||||
echo ✅ 构建完成!输出目录: dist
|
||||
pause
|
||||
|
||||
Reference in New Issue
Block a user