加载失败: {error}
- -- {searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'} -
- {!searchTerm && ( - - )} -diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f5733d8 --- /dev/null +++ b/AGENTS.md @@ -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/`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb0e817 --- /dev/null +++ b/README.md @@ -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 顶部补充截图、动图或在线演示链接,提升展示效果 + diff --git a/mengyaping-backend/.dockerignore b/mengyaping-backend/.dockerignore index 058f085..f582e7a 100644 --- a/mengyaping-backend/.dockerignore +++ b/mengyaping-backend/.dockerignore @@ -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 diff --git a/mengyaping-backend/Dockerfile b/mengyaping-backend/Dockerfile index 45530e2..f1566ec 100644 --- a/mengyaping-backend/Dockerfile +++ b/mengyaping-backend/Dockerfile @@ -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"] diff --git a/mengyaping-backend/config/config.go b/mengyaping-backend/config/config.go index b1813b8..1f66719 100644 --- a/mengyaping-backend/config/config.go +++ b/mengyaping-backend/config/config.go @@ -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) +} diff --git a/mengyaping-backend/docker-compose.yml b/mengyaping-backend/docker-compose.yml index 8e15cca..b64fe0b 100644 --- a/mengyaping-backend/docker-compose.yml +++ b/mengyaping-backend/docker-compose.yml @@ -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 diff --git a/mengyaping-backend/handlers/website.go b/mengyaping-backend/handlers/website.go index d69b3e3..258eb9e 100644 --- a/mengyaping-backend/handlers/website.go +++ b/mengyaping-backend/handlers/website.go @@ -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, + }) +} diff --git a/mengyaping-backend/main.go b/mengyaping-backend/main.go index 8b4fca3..3286ce9 100644 --- a/mengyaping-backend/main.go +++ b/mengyaping-backend/main.go @@ -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) + } +} diff --git a/mengyaping-backend/models/website.go b/mengyaping-backend/models/website.go index 792700c..e3a281a 100644 --- a/mengyaping-backend/models/website.go +++ b/mengyaping-backend/models/website.go @@ -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"` +} diff --git a/mengyaping-backend/router/router.go b/mengyaping-backend/router/router.go index 25a559b..e6177d8 100644 --- a/mengyaping-backend/router/router.go +++ b/mengyaping-backend/router/router.go @@ -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 +} diff --git a/mengyaping-backend/services/monitor.go b/mengyaping-backend/services/monitor.go index 46f5876..757ceb5 100644 --- a/mengyaping-backend/services/monitor.go +++ b/mengyaping-backend/services/monitor.go @@ -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 +} diff --git a/mengyaping-backend/services/website.go b/mengyaping-backend/services/website.go index 23d85fb..4ae914d 100644 --- a/mengyaping-backend/services/website.go +++ b/mengyaping-backend/services/website.go @@ -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 +} diff --git a/mengyaping-backend/storage/storage.go b/mengyaping-backend/storage/storage.go index a027e36..d4737ac 100644 --- a/mengyaping-backend/storage/storage.go +++ b/mengyaping-backend/storage/storage.go @@ -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() +} diff --git a/mengyaping-backend/utils/dns.go b/mengyaping-backend/utils/dns.go new file mode 100644 index 0000000..18b4e04 --- /dev/null +++ b/mengyaping-backend/utils/dns.go @@ -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 +} diff --git a/mengyaping-backend/utils/http.go b/mengyaping-backend/utils/http.go index a398703..aef1015 100644 --- a/mengyaping-backend/utils/http.go +++ b/mengyaping-backend/utils/http.go @@ -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)
监控网站
-{stats.total}
-24h 可用率
-- {formatUptime(stats.avgUptime24h)} -
-7d 可用率
-- {formatUptime(stats.avgUptime7d)} -
-平均延迟
-- {stats.avgLatency} - ms -
-监控网站
+{stats.total}
+24h 可用率
++ {formatUptime(stats.avgUptime24h)} +
+7d 可用率
++ {formatUptime(stats.avgUptime7d)} +
+平均延迟
++ {stats.avgLatency} + ms +
+- {website.website?.title || website.website?.urls?.[0]?.url || '-'} -
- - {/* 可用率条 */} -- {formatLatency(urlStatus.current_state?.latency)} -
-- {formatUptime(urlStatus.uptime_24h)} -
-- {formatUptime(urlStatus.uptime_7d)} -
-
- 网站监控面板
-加载失败: {error}
- -- {searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'} -
- {!searchTerm && ( - - )} -
+ 网站监控面板
+加载失败: {error}
+ ++ {searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'} +
+ {!searchTerm && ( + + )} +