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)]*>([^<]+)`) - 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)]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']`, - `(?i)]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']`, - `(?i)]*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)]*>([^<]+)`) + 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)]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']`, + `(?i)]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']`, + `(?i)]*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 +} diff --git a/mengyaping-backend/utils/id.go b/mengyaping-backend/utils/id.go index 09fa6b7..6a4c379 100644 --- a/mengyaping-backend/utils/id.go +++ b/mengyaping-backend/utils/id.go @@ -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) +} diff --git a/mengyaping-frontend/index.html b/mengyaping-frontend/index.html index 862c185..60260fb 100644 --- a/mengyaping-frontend/index.html +++ b/mengyaping-frontend/index.html @@ -5,8 +5,12 @@ + + + 萌芽Ping - 网站监控面板 - + + diff --git a/mengyaping-frontend/package.json b/mengyaping-frontend/package.json index 9f9aea2..a10477c 100644 --- a/mengyaping-frontend/package.json +++ b/mengyaping-frontend/package.json @@ -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", diff --git a/mengyaping-frontend/public/icons/icon-192.png b/mengyaping-frontend/public/icons/icon-192.png new file mode 100644 index 0000000..b7d28aa Binary files /dev/null and b/mengyaping-frontend/public/icons/icon-192.png differ diff --git a/mengyaping-frontend/public/icons/icon-512-maskable.png b/mengyaping-frontend/public/icons/icon-512-maskable.png new file mode 100644 index 0000000..73902d0 Binary files /dev/null and b/mengyaping-frontend/public/icons/icon-512-maskable.png differ diff --git a/mengyaping-frontend/public/icons/icon-512.png b/mengyaping-frontend/public/icons/icon-512.png new file mode 100644 index 0000000..73902d0 Binary files /dev/null and b/mengyaping-frontend/public/icons/icon-512.png differ diff --git a/mengyaping-frontend/public/manifest.webmanifest b/mengyaping-frontend/public/manifest.webmanifest new file mode 100644 index 0000000..901766e --- /dev/null +++ b/mengyaping-frontend/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/mengyaping-frontend/public/sw.js b/mengyaping-frontend/public/sw.js new file mode 100644 index 0000000..3236616 --- /dev/null +++ b/mengyaping-frontend/public/sw.js @@ -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 + }) + }) + ) +}) diff --git a/mengyaping-frontend/src/App.jsx b/mengyaping-frontend/src/App.jsx index d7994b8..9a8cf16 100644 --- a/mengyaping-frontend/src/App.jsx +++ b/mengyaping-frontend/src/App.jsx @@ -1,7 +1,7 @@ -import Dashboard from './pages/Dashboard' - -function App() { - return -} - -export default App +import Dashboard from './pages/Dashboard' + +function App() { + return +} + +export default App diff --git a/mengyaping-frontend/src/components/StatsCard.jsx b/mengyaping-frontend/src/components/StatsCard.jsx index d9adc0f..7503970 100644 --- a/mengyaping-frontend/src/components/StatsCard.jsx +++ b/mengyaping-frontend/src/components/StatsCard.jsx @@ -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 ( -
- {/* 监控网站数 */} -
-
-
-

监控网站

-

{stats.total}

-
-
- - - -
-
-
- - - {stats.online} 在线 - - | - - - {stats.offline} 离线 - -
-
- - {/* 24小时可用率 */} -
-
-
-

24h 可用率

-

- {formatUptime(stats.avgUptime24h)} -

-
-
- - - -
-
-
-
-
-
- - {/* 7天可用率 */} -
-
-
-

7d 可用率

-

- {formatUptime(stats.avgUptime7d)} -

-
-
- - - -
-
-
-
-
-
- - {/* 平均延迟 */} -
-
-
-

平均延迟

-

- {stats.avgLatency} - ms -

-
-
- - - -
-
-
- - - - 每5分钟检测一次 -
-
-
- ); -} +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 ( +
+ {/* 监控网站数 */} +
+
+
+

监控网站

+

{stats.total}

+
+
+ + + +
+
+
+ + + {stats.online} 在线 + + | + + + {stats.offline} 离线 + +
+
+ + {/* 24小时可用率 */} +
+
+
+

24h 可用率

+

+ {formatUptime(stats.avgUptime24h)} +

+
+
+ + + +
+
+
+
+
+
+ + {/* 7天可用率 */} +
+
+
+

7d 可用率

+

+ {formatUptime(stats.avgUptime7d)} +

+
+
+ + + +
+
+
+
+
+
+ + {/* 平均延迟 */} +
+
+
+

平均延迟

+

+ {stats.avgLatency} + ms +

+
+
+ + + +
+
+
+ + + + 每5分钟检测一次 +
+
+
+ ); +} diff --git a/mengyaping-frontend/src/components/UptimeChart.jsx b/mengyaping-frontend/src/components/UptimeChart.jsx index e533494..83bb260 100644 --- a/mengyaping-frontend/src/components/UptimeChart.jsx +++ b/mengyaping-frontend/src/components/UptimeChart.jsx @@ -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 ( -
- - - - - - - - - - {/* 网格线 */} - {[0, 25, 50, 75, 100].map(v => ( - - - {showLabels && ( - - {v}% - - )} - - ))} - - {/* 填充区域 */} - {areaPath && ( - - )} - - {/* 折线 */} - {pathData && ( - - )} - - {/* 数据点 */} - {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 ( - - ); - })} - - {/* 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 ( - - {chartData[i]?.hour || 0}:00 - - ); - })} - - )} - - - {/* 无数据提示 */} - {!pathData && ( - - 暂无数据 - - )} - -
- ); -} +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 ( +
+ + + + + + + + + + {/* 网格线 */} + {[0, 25, 50, 75, 100].map(v => ( + + + {showLabels && ( + + {v}% + + )} + + ))} + + {/* 填充区域 */} + {areaPath && ( + + )} + + {/* 折线 */} + {pathData && ( + + )} + + {/* 数据点 */} + {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 ( + + ); + })} + + {/* 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 ( + + {chartData[i]?.hour || 0}:00 + + ); + })} + + )} + + + {/* 无数据提示 */} + {!pathData && ( + + 暂无数据 + + )} + +
+ ); +} diff --git a/mengyaping-frontend/src/components/WebsiteCard.jsx b/mengyaping-frontend/src/components/WebsiteCard.jsx index 2a9346c..3f463ca 100644 --- a/mengyaping-frontend/src/components/WebsiteCard.jsx +++ b/mengyaping-frontend/src/components/WebsiteCard.jsx @@ -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 ( -
- {/* 卡片头部 */} -
setExpanded(!expanded)} - > - {/* 第一行:图标、名称、状态 */} -
-
- {/* Favicon */} -
- {website.website?.favicon ? ( - { - e.target.style.display = 'none'; - e.target.nextSibling.style.display = 'flex'; - }} - /> - ) : null} - - {website.website?.name?.[0] || '?'} - -
- - {/* 网站名称 */} -

- {website.website?.name || '未知网站'} -

-
- - {/* 展开箭头 */} - - - -
- - {/* 第二行:状态、延迟、访问按钮 */} -
-
- {/* 状态徽章 */} - - {isUp ? `${statusCode}` : '离线'} - - - {/* 延迟 */} - - {formatLatency(latency)} - -
- - {/* 访问按钮 */} - -
- - {/* 网站描述 */} -

- {website.website?.title || website.website?.urls?.[0]?.url || '-'} -

- - {/* 可用率条 */} -
-
- 24h可用率 - - {formatUptime(website.uptime_24h)} - -
-
-
-
-
-
- - {/* 展开详情 */} - {expanded && ( -
- {/* URL列表 */} -
- {website.url_statuses?.map((urlStatus, index) => ( -
-
- - {urlStatus.url_info?.url} - - - {urlStatus.current_state?.is_up ? urlStatus.current_state?.status_code : '离线'} - -
- -
-
- 延迟 -

- {formatLatency(urlStatus.current_state?.latency)} -

-
-
- 24h -

- {formatUptime(urlStatus.uptime_24h)} -

-
-
- 7d -

- {formatUptime(urlStatus.uptime_7d)} -

-
-
-
- ))} -
- - {/* 操作按钮 */} -
- - - -
- - {/* 最后检测时间 */} -
- 最后检测: {formatTime(website.last_checked)} -
-
- )} -
- ); -} +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 ( +
+ {/* 主体区域 */} +
setExpanded(!expanded)} + > + {/* 头部:图标 + 名称 + 状态 + 访问按钮 */} +
+
+
+ {primaryUrl ? ( + { + e.target.style.display = 'none'; + e.target.nextSibling.style.display = 'flex'; + }} + /> + ) : null} + + {website.website?.name?.[0] || '?'} + +
+
+

+ {website.website?.name || '未知网站'} +

+ + {statusDisplay.text} + +
+
+ {primaryUrl && ( + + )} +
+ + {/* 90 天可用率竖条图 */} +
+
+ {dailyBars.map((bar, i) => ( +
setHoveredBar(i)} + onMouseLeave={() => setHoveredBar(null)} + /> + ))} +
+ + {/* 悬浮提示 */} + {hoveredBar !== null && dailyBars[hoveredBar] && ( +
+ {dailyBars[hoveredBar].date} + {' · '} + {dailyBars[hoveredBar].uptime !== null + ? `${dailyBars[hoveredBar].uptime.toFixed(1)}%` + : '无数据'} +
+ )} +
+ + {/* 底部标签 */} +
+ {HISTORY_DAYS} 天前 + + {hasUptimeData ? `${uptime90d.toFixed(2)}% 可用率` : '-'} + + 今天 +
+ + {/* 状态 / 延迟 / 24h */} +
+ 状态 {isUp ? statusCode : '离线'} + 延迟 {formatLatency(latency)} + 24h {formatUptime(website.uptime_24h)} +
+ + {/* 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) && ( +
+ {ipv4.length > 0 && ( +
+ IPv4 + {ipv4.map((ip, i) => ( + {ip} + ))} +
+ )} + {ipv6.length > 0 && ( +
+ IPv6 + {ipv6.map((ip, i) => ( + {ip} + ))} +
+ )} +
+ ); + })()} +
+ + {/* 展开详情 */} + {expanded && ( +
+ {/* URL 列表 */} +
+ {website.url_statuses?.map((urlStatus, index) => ( +
+
+ + {urlStatus.url_info?.url} + + + {urlStatus.current_state?.is_up ? urlStatus.current_state?.status_code : '离线'} + +
+
+ ))} +
+ + {/* 操作按钮 */} +
+ + {formatTime(website.last_checked)} + +
+ + + +
+
+
+ )} +
+ ); +} diff --git a/mengyaping-frontend/src/components/WebsiteModal.jsx b/mengyaping-frontend/src/components/WebsiteModal.jsx index 905a578..9290b66 100644 --- a/mengyaping-frontend/src/components/WebsiteModal.jsx +++ b/mengyaping-frontend/src/components/WebsiteModal.jsx @@ -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 ( -
-
- {/* 标题 */} -
-

- {editData ? '编辑网站' : '添加监控网站'} -

-
- - {/* 表单 */} -
- {error && ( -
- {error} -
- )} - - {/* 网站名称 */} -
- - 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" - /> -
- - {/* 所属分组 */} -
- - -
- - {/* 网站地址列表 */} -
- -

- 一个网站可以有多个访问地址,将分别监控 -

- -
- {formData.urls.map((url, index) => ( -
- 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 && ( - - )} -
- ))} -
- - -
-
- - {/* 按钮 */} -
- - -
-
-
- ); -} +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 ( +
+
+ {/* 标题 */} +
+

+ {editData ? '编辑网站' : '添加监控网站'} +

+
+ + {/* 表单 */} +
+ {error && ( +
+ {error} +
+ )} + + {/* 网站名称 */} +
+ + 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" + /> +
+ + {/* 所属分组(多选) */} +
+ +
+ {groups.map(group => { + const checked = formData.groups.includes(group.id); + return ( + + ); + })} +
+
+ + {/* 网站地址列表 */} +
+ +

+ 一个网站可以有多个访问地址,将分别监控 +

+ +
+ {formData.urls.map((url, index) => ( +
+ 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 && ( + + )} +
+ ))} +
+ + +
+
+ + {/* 按钮 */} +
+ + +
+
+
+ ); +} diff --git a/mengyaping-frontend/src/hooks/useMonitor.js b/mengyaping-frontend/src/hooks/useMonitor.js index bcd0a68..9a4b0b7 100644 --- a/mengyaping-frontend/src/hooks/useMonitor.js +++ b/mengyaping-frontend/src/hooks/useMonitor.js @@ -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'; +} diff --git a/mengyaping-frontend/src/index.css b/mengyaping-frontend/src/index.css index c3941e4..6355e06 100644 --- a/mengyaping-frontend/src/index.css +++ b/mengyaping-frontend/src/index.css @@ -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; + } +} + diff --git a/mengyaping-frontend/src/main.jsx b/mengyaping-frontend/src/main.jsx index b9a1a6d..fba4204 100644 --- a/mengyaping-frontend/src/main.jsx +++ b/mengyaping-frontend/src/main.jsx @@ -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( diff --git a/mengyaping-frontend/src/pages/Dashboard.jsx b/mengyaping-frontend/src/pages/Dashboard.jsx index a085c77..29cef3c 100644 --- a/mengyaping-frontend/src/pages/Dashboard.jsx +++ b/mengyaping-frontend/src/pages/Dashboard.jsx @@ -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 ( -
- {/* 顶部导航 */} -
-
-
- {/* Logo */} -
-
- 萌芽Ping -
-
-

- 萌芽Ping -

-

网站监控面板

-
-
- - {/* 添加按钮 */} - -
-
-
- - {/* 主内容区 */} -
- {/* 统计概览 */} -
- -
- - {/* 过滤和搜索 */} -
-
- {/* 分组选择 */} -
- - {groups?.map(group => ( - - ))} -
- - {/* 搜索框 */} -
-
- - - - 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" - /> -
-
-
-
- - {/* 网站列表 */} -
- {loading && !websites ? ( -
-
- 加载中... -
- ) : error ? ( -
-
- - - -
-

加载失败: {error}

- -
- ) : filteredWebsites.length === 0 ? ( -
-
- - - -
-

- {searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'} -

- {!searchTerm && ( - - )} -
- ) : ( -
- {Object.keys(groupedWebsites).map(groupId => ( -
- {selectedGroup === 'all' && ( -

- - {getGroupName(groupId)} - ({groupedWebsites[groupId].length}) -

- )} -
- {groupedWebsites[groupId].map(website => ( - - ))} -
-
- ))} -
- )} -
-
- - {/* 底部信息 */} -
-

萌芽Ping © 2026

-
- - {/* 添加/编辑弹窗 */} - setModalOpen(false)} - onSuccess={refresh} - editData={editData} - /> -
- ); -} +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 ( +
+ {/* 顶部导航 */} +
+
+
+ {/* Logo */} +
+
+ 萌芽Ping +
+
+

+ 萌芽Ping +

+

网站监控面板

+
+
+ + {/* 添加按钮 */} + +
+
+
+ + {/* 主内容区 */} +
+ {/* 统计概览 */} +
+ +
+ + {/* 过滤和搜索 */} +
+
+ {/* 分组选择 */} +
+ + {groups?.map(group => ( + + ))} +
+ + {/* 搜索框 */} +
+
+ + + + 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" + /> +
+
+
+
+ + {/* 网站列表 */} +
+ {loading && !websites ? ( +
+
+ 加载中... +
+ ) : error ? ( +
+
+ + + +
+

加载失败: {error}

+ +
+ ) : filteredWebsites.length === 0 ? ( +
+
+ + + +
+

+ {searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'} +

+ {!searchTerm && ( + + )} +
+ ) : ( +
+ {Object.keys(groupedWebsites).map(groupId => ( +
+ {selectedGroup === 'all' && ( +

+ + {getGroupName(groupId)} + ({groupedWebsites[groupId].length}) +

+ )} +
+ {groupedWebsites[groupId].map(website => ( + + ))} +
+
+ ))} +
+ )} +
+
+ + {/* 底部信息 */} +
+

萌芽Ping © 2026

+
+ + {/* 添加/编辑弹窗 */} + setModalOpen(false)} + onSuccess={refresh} + editData={editData} + /> +
+ ); +} diff --git a/mengyaping-frontend/src/services/api.js b/mengyaping-frontend/src/services/api.js index ba2c8b9..6ff0981 100644 --- a/mengyaping-frontend/src/services/api.js +++ b/mengyaping-frontend/src/services/api.js @@ -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 }), + }); +} diff --git a/mengyaping-frontend/tailwind.config.js b/mengyaping-frontend/tailwind.config.js index 38302b3..3a23526 100644 --- a/mengyaping-frontend/tailwind.config.js +++ b/mengyaping-frontend/tailwind.config.js @@ -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: [], +} diff --git a/mengyaping-frontend/vite.config.js b/mengyaping-frontend/vite.config.js index c4069b7..0bfdd5c 100644 --- a/mengyaping-frontend/vite.config.js +++ b/mengyaping-frontend/vite.config.js @@ -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, + }, }) diff --git a/开启前端.bat b/开启前端.bat index 97f4f08..bfc8f4f 100644 --- a/开启前端.bat +++ b/开启前端.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 diff --git a/开启后端.bat b/开启后端.bat index 84dd2a0..bdec0a4 100644 --- a/开启后端.bat +++ b/开启后端.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 diff --git a/构建前端.bat b/构建前端.bat index c221bdd..a5ba769 100644 --- a/构建前端.bat +++ b/构建前端.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