chore: sync local updates
This commit is contained in:
@@ -5,7 +5,7 @@ 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-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/`.
|
- `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.
|
Runtime site/group data is in MySQL. Optional `mengyaping-backend/data/` is only for local tooling (e.g. `cmd/importjson` reading `websites.json` / `groups.json`). App settings and admin token live in table `monitor_kv`.
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
Frontend (run inside `mengyaping-frontend/`):
|
Frontend (run inside `mengyaping-frontend/`):
|
||||||
@@ -44,4 +44,4 @@ Each PR should include:
|
|||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
- Do not commit secrets, tokens, or private endpoints.
|
- Do not commit secrets, tokens, or private endpoints.
|
||||||
- Frontend dev API target is `http://localhost:8080/api` in `mengyaping-frontend/src/services/api.js`.
|
- 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/`.
|
- Commit only sanitized sample data in `mengyaping-backend/data/`.
|
||||||
|
|||||||
3
mengyaping-backend/.gitignore
vendored
Normal file
3
mengyaping-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 本地交叉编译产物(部署时再生成)
|
||||||
|
dist/mengyaping-backend
|
||||||
|
dist/mengyaping-backend.exe
|
||||||
@@ -1,56 +1,31 @@
|
|||||||
# 多阶段构建 - 使用官方 Golang 镜像作为构建环境
|
# 生产镜像:使用本机预编译的 Linux amd64 二进制(避免在服务器 docker build 时再跑 go build)
|
||||||
FROM golang:1.25-alpine AS builder
|
# Windows: 先双击运行 build-linux-amd64.bat,生成 dist/mengyaping-backend 后再执行:
|
||||||
|
# docker compose build
|
||||||
|
# 若需在容器内编译,请使用: docker build -f Dockerfile.source .
|
||||||
|
|
||||||
# 设置工作目录
|
FROM alpine:3.21
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 安装必要的构建工具
|
RUN apk --no-cache add ca-certificates tzdata wget
|
||||||
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 && \
|
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||||
echo "Asia/Shanghai" > /etc/timezone
|
echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
|
||||||
# 创建非 root 用户
|
|
||||||
RUN addgroup -g 1000 appuser && \
|
RUN addgroup -g 1000 appuser && \
|
||||||
adduser -D -u 1000 -G appuser appuser
|
adduser -D -u 1000 -G appuser appuser
|
||||||
|
|
||||||
# 设置工作目录
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 从构建阶段复制编译好的二进制文件
|
COPY dist/mengyaping-backend /app/mengyaping-backend
|
||||||
COPY --from=builder /app/mengyaping-backend .
|
|
||||||
|
|
||||||
# 创建数据目录
|
RUN chmod +x /app/mengyaping-backend && \
|
||||||
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
mkdir -p /app/data && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
|
||||||
# 切换到非 root 用户
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
# 暴露端口
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# 健康检查
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
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 wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
|
||||||
|
|
||||||
# 运行应用程序
|
|
||||||
CMD ["./mengyaping-backend"]
|
CMD ["./mengyaping-backend"]
|
||||||
|
|||||||
35
mengyaping-backend/build-linux-amd64.bat
Normal file
35
mengyaping-backend/build-linux-amd64.bat
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul 2>&1
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
where go >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Go not found in PATH. Install Go and reopen the terminal.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "DIST=dist"
|
||||||
|
if not exist "%DIST%" mkdir "%DIST%"
|
||||||
|
|
||||||
|
set "GOOS=linux"
|
||||||
|
set "GOARCH=amd64"
|
||||||
|
set "CGO_ENABLED=0"
|
||||||
|
|
||||||
|
echo Cross-compile linux/amd64 -^> "%DIST%\mengyaping-backend"
|
||||||
|
go version
|
||||||
|
go build -trimpath -ldflags="-s -w" -o "%DIST%\mengyaping-backend" .
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] go build failed. See messages above.
|
||||||
|
echo Tips: go.mod requires Go 1.25+; run from mengyaping-backend folder.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [OK] "%CD%\%DIST%\mengyaping-backend"
|
||||||
|
echo Next: copy this folder to server, then: docker compose build
|
||||||
|
endlocal
|
||||||
|
exit /b 0
|
||||||
136
mengyaping-backend/cmd/importjson/main.go
Normal file
136
mengyaping-backend/cmd/importjson/main.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// 将 data/*.json(站点与分组)导入 MySQL。建议空库首次使用,或加 -wipe 清空业务表后再导。
|
||||||
|
//
|
||||||
|
// go run ./cmd/importjson -data ./data -wipe
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"mengyaping-backend/config"
|
||||||
|
"mengyaping-backend/models"
|
||||||
|
"mengyaping-backend/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dataDir := flag.String("data", "./data", "JSON 目录(websites.json、groups.json)")
|
||||||
|
wipe := flag.Bool("wipe", false, "导入前 TRUNCATE 所有 monitor_* 表(慎用)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
// 大批量 INSERT 时单条超过 200ms 会触发 GORM 默认「SLOW SQL」提示,易被误认为报错;导入工具改为静默 SQL 日志
|
||||||
|
db, err := gorm.Open(mysql.Open(cfg.DatabaseDSN()), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&storage.MonitorGroup{},
|
||||||
|
&storage.MonitorWebsite{},
|
||||||
|
&storage.MonitorWebsiteURL{},
|
||||||
|
&storage.MonitorWebsiteGroup{},
|
||||||
|
&storage.MonitorProbeLatest{},
|
||||||
|
&storage.MonitorProbeHour{},
|
||||||
|
&storage.MonitorProbeDay{},
|
||||||
|
&storage.MonitorKV{},
|
||||||
|
); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *wipe {
|
||||||
|
_ = db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error
|
||||||
|
for _, tbl := range []string{
|
||||||
|
"monitor_probe_latest",
|
||||||
|
"monitor_probe_hour",
|
||||||
|
"monitor_probe_day",
|
||||||
|
"monitor_kv",
|
||||||
|
"monitor_checks",
|
||||||
|
"monitor_website_urls",
|
||||||
|
"monitor_website_groups",
|
||||||
|
"monitor_websites",
|
||||||
|
"monitor_groups",
|
||||||
|
} {
|
||||||
|
_ = db.Exec("TRUNCATE TABLE " + tbl).Error
|
||||||
|
}
|
||||||
|
_ = db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error
|
||||||
|
log.Println("已清空 monitor_* 表")
|
||||||
|
}
|
||||||
|
|
||||||
|
websitesPath := filepath.Join(*dataDir, "websites.json")
|
||||||
|
raw, err := os.ReadFile(websitesPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("读取 websites.json: %v", err)
|
||||||
|
}
|
||||||
|
var websites []models.Website
|
||||||
|
if err := json.Unmarshal(raw, &websites); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
groupsPath := filepath.Join(*dataDir, "groups.json")
|
||||||
|
var fileGroups []models.Group
|
||||||
|
if g, err := os.ReadFile(groupsPath); err == nil {
|
||||||
|
_ = json.Unmarshal(g, &fileGroups)
|
||||||
|
}
|
||||||
|
if len(fileGroups) == 0 {
|
||||||
|
fileGroups = models.DefaultGroups
|
||||||
|
}
|
||||||
|
for i, g := range fileGroups {
|
||||||
|
if err := db.Create(&storage.MonitorGroup{
|
||||||
|
ID: g.ID, Name: g.Name, SortOrder: i + 1,
|
||||||
|
}).Error; err != nil {
|
||||||
|
log.Printf("导入分组 %s: %v", g.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, w := range websites {
|
||||||
|
if len(w.Groups) == 0 && w.Group != "" {
|
||||||
|
w.Groups = []string{w.Group}
|
||||||
|
}
|
||||||
|
row := &storage.MonitorWebsite{
|
||||||
|
ID: w.ID,
|
||||||
|
Name: w.Name,
|
||||||
|
LegacyGroup: "",
|
||||||
|
Favicon: w.Favicon,
|
||||||
|
Title: w.Title,
|
||||||
|
IPAddresses: datatypes.JSON(mustJSON(w.IPAddresses)),
|
||||||
|
CreatedAt: w.CreatedAt,
|
||||||
|
UpdatedAt: w.UpdatedAt,
|
||||||
|
}
|
||||||
|
if err := db.Create(row).Error; err != nil {
|
||||||
|
log.Printf("网站 %s: %v", w.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, u := range w.URLs {
|
||||||
|
_ = db.Create(&storage.MonitorWebsiteURL{
|
||||||
|
WebsiteID: w.ID, URLID: u.ID, URL: u.URL, Remark: u.Remark, SortOrder: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, gid := range w.Groups {
|
||||||
|
_ = db.Create(&storage.MonitorWebsiteGroup{WebsiteID: w.ID, GroupID: gid})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("已导入 %d 个网站\n", len(websites))
|
||||||
|
log.Println("探测历史由运行时写入 monitor_probe_* 汇总表,不再从 records.json 导入")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(v []string) []byte {
|
||||||
|
if v == nil {
|
||||||
|
return []byte("[]")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return []byte("[]")
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -1,176 +1,93 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"fmt"
|
||||||
"fmt"
|
"os"
|
||||||
"os"
|
"sync"
|
||||||
"sync"
|
"time"
|
||||||
"time"
|
)
|
||||||
)
|
|
||||||
|
// Config 应用配置(持久化在 MySQL monitor_kv,首次启动前由环境变量提供默认值;MySQL 连接信息首次连接仍依赖环境变量/DB_DSN)
|
||||||
// Config 应用配置
|
type Config struct {
|
||||||
type Config struct {
|
Server ServerConfig `json:"server"`
|
||||||
Server ServerConfig `json:"server"`
|
Monitor MonitorConfig `json:"monitor"`
|
||||||
Monitor MonitorConfig `json:"monitor"`
|
DataPath string `json:"data_path"`
|
||||||
DataPath string `json:"data_path"`
|
Database DatabaseConfig `json:"database"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig 服务器配置
|
// ServerConfig 服务器配置
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port string `json:"port"`
|
Port string `json:"port"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonitorConfig 监控配置
|
// MonitorConfig 监控配置
|
||||||
type MonitorConfig struct {
|
type MonitorConfig struct {
|
||||||
Interval time.Duration `json:"interval"` // 检测间隔
|
Interval time.Duration `json:"interval"` // 检测间隔
|
||||||
Timeout time.Duration `json:"timeout"` // 请求超时时间
|
Timeout time.Duration `json:"timeout"` // 请求超时时间
|
||||||
RetryCount int `json:"retry_count"` // 重试次数
|
RetryCount int `json:"retry_count"` // 重试次数
|
||||||
HistoryDays int `json:"history_days"` // 保留历史天数
|
HistoryDays int `json:"history_days"` // 保留历史天数
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfg *Config
|
cfg *Config
|
||||||
once sync.Once
|
once sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetConfig 获取配置单例
|
// GetConfig 获取配置单例(连接 MySQL 后由 storage 根据 monitor_kv 覆盖)
|
||||||
func GetConfig() *Config {
|
func GetConfig() *Config {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
cfg = &Config{
|
cfg = &Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
Port: getEnv("SERVER_PORT", "8080"),
|
Port: getEnv("SERVER_PORT", "8080"),
|
||||||
Host: getEnv("SERVER_HOST", "0.0.0.0"),
|
Host: getEnv("SERVER_HOST", "0.0.0.0"),
|
||||||
},
|
},
|
||||||
Monitor: MonitorConfig{
|
Monitor: MonitorConfig{
|
||||||
Interval: parseDuration(getEnv("MONITOR_INTERVAL", "1h"), 1*time.Hour),
|
Interval: parseDuration(getEnv("MONITOR_INTERVAL", "30m"), 30*time.Minute),
|
||||||
Timeout: parseDuration(getEnv("MONITOR_TIMEOUT", "10s"), 10*time.Second),
|
Timeout: parseDuration(getEnv("MONITOR_TIMEOUT", "10s"), 10*time.Second),
|
||||||
RetryCount: parseInt(getEnv("MONITOR_RETRY_COUNT", "3"), 3),
|
RetryCount: parseInt(getEnv("MONITOR_RETRY_COUNT", "3"), 3),
|
||||||
HistoryDays: parseInt(getEnv("MONITOR_HISTORY_DAYS", "90"), 90),
|
HistoryDays: parseInt(getEnv("MONITOR_HISTORY_DAYS", "90"), 90),
|
||||||
},
|
},
|
||||||
DataPath: getEnv("DATA_PATH", "./data"),
|
DataPath: getEnv("DATA_PATH", "./data"),
|
||||||
}
|
Database: DatabaseConfig{
|
||||||
|
Host: getEnv("DB_HOST", "192.168.1.100"),
|
||||||
// 尝试从配置文件加载(会覆盖环境变量配置)
|
Port: getEnv("DB_PORT", "3306"),
|
||||||
loadConfigFromFile()
|
User: getEnv("DB_USER", "mengyaping"),
|
||||||
})
|
Password: getEnv("DB_PASSWORD", "mengyaping"),
|
||||||
return cfg
|
Database: getEnv("DB_NAME", "mengyaping"),
|
||||||
}
|
},
|
||||||
|
}
|
||||||
// getEnv 获取环境变量,如果不存在则返回默认值
|
})
|
||||||
func getEnv(key, defaultValue string) string {
|
return cfg
|
||||||
if value := os.Getenv(key); value != "" {
|
}
|
||||||
return value
|
|
||||||
}
|
// getEnv 获取环境变量,如果不存在则返回默认值
|
||||||
return defaultValue
|
func getEnv(key, defaultValue string) string {
|
||||||
}
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
// parseInt 解析整数环境变量
|
}
|
||||||
func parseInt(value string, defaultValue int) int {
|
return defaultValue
|
||||||
if value == "" {
|
}
|
||||||
return defaultValue
|
|
||||||
}
|
// parseInt 解析整数环境变量
|
||||||
var result int
|
func parseInt(value string, defaultValue int) int {
|
||||||
if _, err := fmt.Sscanf(value, "%d", &result); err != nil {
|
if value == "" {
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
return result
|
var result int
|
||||||
}
|
if _, err := fmt.Sscanf(value, "%d", &result); err != nil {
|
||||||
|
return defaultValue
|
||||||
// parseDuration 解析时间间隔环境变量
|
}
|
||||||
func parseDuration(value string, defaultValue time.Duration) time.Duration {
|
return result
|
||||||
if value == "" {
|
}
|
||||||
return defaultValue
|
|
||||||
}
|
// parseDuration 解析时间间隔环境变量
|
||||||
if duration, err := time.ParseDuration(value); err == nil {
|
func parseDuration(value string, defaultValue time.Duration) time.Duration {
|
||||||
return duration
|
if value == "" {
|
||||||
}
|
return defaultValue
|
||||||
return defaultValue
|
}
|
||||||
}
|
if duration, err := time.ParseDuration(value); err == nil {
|
||||||
|
return duration
|
||||||
// loadConfigFromFile 从文件加载配置
|
}
|
||||||
func loadConfigFromFile() {
|
return defaultValue
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
25
mengyaping-backend/config/database.go
Normal file
25
mengyaping-backend/config/database.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DatabaseConfig MySQL 连接(环境变量 DB_* 可覆盖;亦支持 DB_DSN 整串)
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port string `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseDSN 返回 GORM/MySQL driver 用连接串
|
||||||
|
func (c *Config) DatabaseDSN() string {
|
||||||
|
if dsn := os.Getenv("DB_DSN"); dsn != "" {
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
|
d := c.Database
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
d.User, d.Password, d.Host, d.Port, d.Database)
|
||||||
|
}
|
||||||
38
mengyaping-backend/config/monitor_interval.go
Normal file
38
mengyaping-backend/config/monitor_interval.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "slices"
|
||||||
|
|
||||||
|
// AllowedMonitorIntervalMinutes 后台预设的全局检测周期(分钟)
|
||||||
|
var AllowedMonitorIntervalMinutes = []int{10, 20, 30, 60, 180, 360}
|
||||||
|
|
||||||
|
// IsAllowedMonitorInterval 是否为允许的检测间隔(分钟)
|
||||||
|
func IsAllowedMonitorInterval(minutes int) bool {
|
||||||
|
return minutes > 0 && slices.Contains(AllowedMonitorIntervalMinutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapMonitorIntervalMinutes 将分钟数裁剪到最近的预设值(用于读取旧配置)
|
||||||
|
func SnapMonitorIntervalMinutes(minutes int) int {
|
||||||
|
if minutes <= 0 {
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
if IsAllowedMonitorInterval(minutes) {
|
||||||
|
return minutes
|
||||||
|
}
|
||||||
|
best := AllowedMonitorIntervalMinutes[0]
|
||||||
|
bestDist := absInt(minutes - best)
|
||||||
|
for _, v := range AllowedMonitorIntervalMinutes[1:] {
|
||||||
|
d := absInt(minutes - v)
|
||||||
|
if d < bestDist {
|
||||||
|
bestDist = d
|
||||||
|
best = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func absInt(x int) int {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
24
mengyaping-backend/config/monitor_interval_test.go
Normal file
24
mengyaping-backend/config/monitor_interval_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsAllowedMonitorInterval(t *testing.T) {
|
||||||
|
if !IsAllowedMonitorInterval(10) || !IsAllowedMonitorInterval(360) {
|
||||||
|
t.Fatal("expected 10 and 360 allowed")
|
||||||
|
}
|
||||||
|
if IsAllowedMonitorInterval(5) || IsAllowedMonitorInterval(45) {
|
||||||
|
t.Fatal("expected 5 and 45 disallowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapMonitorIntervalMinutes(t *testing.T) {
|
||||||
|
if SnapMonitorIntervalMinutes(5) != 10 {
|
||||||
|
t.Fatalf("5 -> 10, got %d", SnapMonitorIntervalMinutes(5))
|
||||||
|
}
|
||||||
|
if SnapMonitorIntervalMinutes(7) != 10 {
|
||||||
|
t.Fatalf("7 -> 10, got %d", SnapMonitorIntervalMinutes(7))
|
||||||
|
}
|
||||||
|
if SnapMonitorIntervalMinutes(25) != 20 {
|
||||||
|
t.Fatalf("25 -> 20, got %d", SnapMonitorIntervalMinutes(25))
|
||||||
|
}
|
||||||
|
}
|
||||||
0
mengyaping-backend/data/.gitkeep
Normal file
0
mengyaping-backend/data/.gitkeep
Normal file
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"server": {
|
|
||||||
"port": "8080",
|
|
||||||
"host": "0.0.0.0"
|
|
||||||
},
|
|
||||||
"monitor": {
|
|
||||||
"interval_minutes": 5,
|
|
||||||
"timeout_seconds": 10,
|
|
||||||
"retry_count": 3,
|
|
||||||
"history_days": 7
|
|
||||||
},
|
|
||||||
"data_path": "./data"
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "self-made",
|
|
||||||
"name": "自制网站"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "self-deploy",
|
|
||||||
"name": "自部署网站"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin",
|
|
||||||
"name": "管理员网站"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "api",
|
|
||||||
"name": "API网站"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,821 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "188cb48c619bd5d896bff546",
|
|
||||||
"name": "萌芽主页",
|
|
||||||
"groups": [
|
|
||||||
"self-made"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "d19b0a475334",
|
|
||||||
"url": "https://shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://shumengya.top/favicon.ico",
|
|
||||||
"title": "萌芽主页",
|
|
||||||
"created_at": "2026-01-21T17:21:08.6757862+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:33.6663492+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb49298d72b78a37301a1",
|
|
||||||
"name": "萌芽备忘录",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "9d25ae024f36",
|
|
||||||
"url": "https://memos.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://memos.shumengya.top/logo.webp",
|
|
||||||
"title": "Memos",
|
|
||||||
"created_at": "2026-01-21T17:21:35.3722254+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:34.4657931+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb52c04cf639cfc2c8ba4",
|
|
||||||
"name": "萌芽盘",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "cfe6229fb86b",
|
|
||||||
"url": "https://pan.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://img.shumengya.top/i/2026/01/04/695a660870959.png",
|
|
||||||
"title": "萌芽盘",
|
|
||||||
"created_at": "2026-01-21T17:32:34.3136511+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:35.6184204+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb53623f46b7872bf17b6",
|
|
||||||
"name": "萌芽Git仓库",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "b53594e7285d",
|
|
||||||
"url": "https://git.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://git.shumengya.top/assets/img/favicon.svg",
|
|
||||||
"title": "萌芽Git仓库",
|
|
||||||
"created_at": "2026-01-21T17:33:17.7858446+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:36.4115003+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb54b93ddd1e487bf347e",
|
|
||||||
"name": "萌芽图床",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "46d822694242",
|
|
||||||
"url": "https://image.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://image.shumengya.top/favicon.ico",
|
|
||||||
"title": "萌芽图床",
|
|
||||||
"created_at": "2026-01-21T17:34:49.8577249+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:38.40321+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb5507dcd2da0559bd6dd",
|
|
||||||
"name": "萌芽笔记",
|
|
||||||
"groups": [
|
|
||||||
"self-made"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "6d5125db9d81",
|
|
||||||
"url": "https://note.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://note.shumengya.top/logo.png",
|
|
||||||
"title": "萌芽笔记",
|
|
||||||
"created_at": "2026-01-21T17:35:10.962372+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:40.1738642+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb55693193b80d2383cc3",
|
|
||||||
"name": "萌芽作品集",
|
|
||||||
"groups": [
|
|
||||||
"self-made"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "74a3b6e1f50e",
|
|
||||||
"url": "https://work.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://work.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-21T17:35:37.0894816+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:40.976149+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb56d8df57dc8c66d42ee",
|
|
||||||
"name": "万象口袋",
|
|
||||||
"groups": [
|
|
||||||
"self-made"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "e8f8fd4cf9b9",
|
|
||||||
"url": "https://infogenie.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://infogenie.shumengya.top/assets/logo.png",
|
|
||||||
"title": "万象口袋",
|
|
||||||
"created_at": "2026-01-21T17:37:15.787501+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:42.7325255+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb574b122f71087e53ae1",
|
|
||||||
"name": "萌芽问卷",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "2f9ffdedd860",
|
|
||||||
"url": "https://survey.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://survey.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-21T17:37:46.4424548+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:44.9725726+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb5a19b836e80d93df86a",
|
|
||||||
"name": "萌芽短链",
|
|
||||||
"groups": [
|
|
||||||
"self-made"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "8a4ebcb7eb03",
|
|
||||||
"url": "https://short.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://short.shumengya.top/logo.png",
|
|
||||||
"title": "萌芽短链",
|
|
||||||
"created_at": "2026-01-21T17:40:59.3532064+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:45.7693627+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb6da20586d34df88b086",
|
|
||||||
"name": "萌芽漂流瓶",
|
|
||||||
"groups": [
|
|
||||||
"self-made"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "e7e250eb8985",
|
|
||||||
"url": "https://bottle.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://bottle.shumengya.top/logo.png",
|
|
||||||
"title": "萌芽漂流瓶(´,,•ω•,,)♡",
|
|
||||||
"created_at": "2026-01-21T18:03:21.6115541+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:46.5656927+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb6ff716e2590408704ee",
|
|
||||||
"name": "编程速查表[CloudFlare]",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "85620784f1a3",
|
|
||||||
"url": "https://reference.smyhub.com",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"188.114.96.0",
|
|
||||||
"188.114.97.0",
|
|
||||||
"2a06:98c1:3121::",
|
|
||||||
"2a06:98c1:3120::"
|
|
||||||
],
|
|
||||||
"favicon": "https://reference.smyhub.com/icons/favicon.svg",
|
|
||||||
"title": "Quick Reference\n \u0026#x26; Quick Reference",
|
|
||||||
"created_at": "2026-01-21T18:06:01.885722+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:48.1727895+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb708e8cf52a49a436570",
|
|
||||||
"name": "思绪思维导图[CloudFlare]",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "dcc07e30a25a",
|
|
||||||
"url": "https://mind-map.smyhub.com",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"favicon": "https://mind-map.smyhub.com/dist/logo.ico",
|
|
||||||
"title": "思绪思维导图",
|
|
||||||
"created_at": "2026-01-21T18:06:42.5432849+08:00",
|
|
||||||
"updated_at": "2026-01-27T13:10:46.0879149+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb71029ad43105a3dc6c0",
|
|
||||||
"name": "it-tools工具集[CloudFlare]",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "f278e65dd498",
|
|
||||||
"url": "https://it-tools.smyhub.com",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"188.114.97.0",
|
|
||||||
"188.114.96.0",
|
|
||||||
"2a06:98c1:3121::",
|
|
||||||
"2a06:98c1:3120::"
|
|
||||||
],
|
|
||||||
"favicon": "https://it-tools.smyhub.com/favicon.ico",
|
|
||||||
"title": "IT Tools - Handy online tools for developers",
|
|
||||||
"created_at": "2026-01-21T18:07:13.6963428+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:49.7591315+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb71b527c48bc3c344acc",
|
|
||||||
"name": "xtools工具集[CloudFlare]",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "13cdc2afab17",
|
|
||||||
"url": "https://xtools.smyhub.com",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"188.114.96.0",
|
|
||||||
"188.114.97.0",
|
|
||||||
"2a06:98c1:3121::",
|
|
||||||
"2a06:98c1:3120::"
|
|
||||||
],
|
|
||||||
"favicon": "https://xtools.smyhub.com/favicon.ico",
|
|
||||||
"title": "百川云常用工具",
|
|
||||||
"created_at": "2026-01-21T18:08:01.6256391+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:52.8447254+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb72555e8d5c4f0d7aa98",
|
|
||||||
"name": "萌芽监控面板",
|
|
||||||
"groups": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "421fe08ece5e",
|
|
||||||
"url": "http://monitor.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "http://monitor.shumengya.top/logo.png",
|
|
||||||
"title": "萌芽监控面板",
|
|
||||||
"created_at": "2026-01-21T18:08:44.6327577+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:53.6453208+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb73272be1c9c5a5d6812",
|
|
||||||
"name": "在线office",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "cba98c3c329b",
|
|
||||||
"url": "https://office.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://office.shumengya.top/favicon.ico",
|
|
||||||
"title": "404 Not Found",
|
|
||||||
"created_at": "2026-01-21T18:09:40.9510719+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:54.436477+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb7372655f3841e82a210",
|
|
||||||
"name": "网页魔方[CloudFlare]",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "ed4c7dcd0565",
|
|
||||||
"url": "https://cube.smyhub.com",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"favicon": "https://cube.smyhub.com/favicon.ico",
|
|
||||||
"title": "HTML5 3D魔方小游戏",
|
|
||||||
"created_at": "2026-01-21T18:10:01.1440137+08:00",
|
|
||||||
"updated_at": "2026-01-27T13:12:55.4496439+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb764a98dbb2453c14b8f",
|
|
||||||
"name": "60sAPI集合",
|
|
||||||
"groups": [
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "2f0596587997",
|
|
||||||
"url": "https://60s.api.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://60s.api.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-21T18:13:16.6194034+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:56.3727134+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb77f231fe68c73e24b29",
|
|
||||||
"name": "萌芽主页后端API",
|
|
||||||
"groups": [
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "c182709178d9",
|
|
||||||
"url": "https://nav.api.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://nav.api.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-21T18:15:10.3277851+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:57.5163074+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb789a76896788f139a96",
|
|
||||||
"name": "萌芽笔记后端API",
|
|
||||||
"groups": [
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "2b42ed612dd3",
|
|
||||||
"url": "https://note.api.shumengya.top/api/tree",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://note.api.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-21T18:15:55.4968142+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:58.307067+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb7934d5d413890dea61f",
|
|
||||||
"name": "萌芽作品集后端API",
|
|
||||||
"groups": [
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "d841646c7b41",
|
|
||||||
"url": "https://work.api.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://work.api.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-21T18:16:36.935795+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:59.0980222+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb79ed2aeb85cabcf6e66",
|
|
||||||
"name": "万象口袋后端API",
|
|
||||||
"groups": [
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "7c8ff0e48be4",
|
|
||||||
"url": "https://infogenie.api.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://infogenie.api.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-21T18:17:26.4171439+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:12:59.8921428+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb7b7b154ddbc1dc4b14d",
|
|
||||||
"name": "萌芽短链后端API",
|
|
||||||
"groups": [
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "ef0443476dd8",
|
|
||||||
"url": "https://short.api.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://short.api.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-21T18:19:13.2317895+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:02.3005616+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cb7c8586dd2c4dcf4bd1e",
|
|
||||||
"name": "萌芽漂流瓶后端API",
|
|
||||||
"groups": [
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "432740a25595",
|
|
||||||
"url": "https://bottle.api.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://bottle.api.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-21T18:20:24.7546969+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:03.0940218+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cf6ed769b75e497cd2a4a",
|
|
||||||
"name": "大萌芽1Panel面板[WG]",
|
|
||||||
"groups": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "a3e3ff7839f1",
|
|
||||||
"url": "http://10.0.0.233:19132/smy",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"10.0.0.233"
|
|
||||||
],
|
|
||||||
"favicon": "http://10.0.0.233:19132/favicon.ico",
|
|
||||||
"title": "loading...",
|
|
||||||
"created_at": "2026-01-22T13:37:33.4073441+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:03.5972427+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cf6f322f76b90c35c3484",
|
|
||||||
"name": "小萌芽1Panel面板[WG]",
|
|
||||||
"groups": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "76b6f9fe4f04",
|
|
||||||
"url": "http://10.0.0.100:19132/smy",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"10.0.0.100"
|
|
||||||
],
|
|
||||||
"favicon": "http://10.0.0.100:19132/favicon.ico",
|
|
||||||
"title": "loading...",
|
|
||||||
"created_at": "2026-01-22T13:37:57.7738884+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:04.1047503+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cf7019e56e618827f4927",
|
|
||||||
"name": "大萌芽Portaintor面板[WG]",
|
|
||||||
"groups": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "093d47789bb5",
|
|
||||||
"url": "http://10.0.0.233:8484/",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"10.0.0.233"
|
|
||||||
],
|
|
||||||
"favicon": "http://10.0.0.233:8484/dd5d4c0b208895c5a7de.png",
|
|
||||||
"title": "Portainer",
|
|
||||||
"created_at": "2026-01-22T13:38:59.9732854+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:04.6105552+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cf70700922fa4041f6470",
|
|
||||||
"name": "小萌芽Portaintor面板[WG]",
|
|
||||||
"groups": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "777c292c7696",
|
|
||||||
"url": "http://10.0.0.100:8484/",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"10.0.0.100"
|
|
||||||
],
|
|
||||||
"favicon": "http://10.0.0.100:8484/dd5d4c0b208895c5a7de.png",
|
|
||||||
"title": "Portainer",
|
|
||||||
"created_at": "2026-01-22T13:39:23.0961745+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:05.112894+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cf728ed2992f06a8f9e52",
|
|
||||||
"name": "easytier面板",
|
|
||||||
"groups": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "0a0a59069228",
|
|
||||||
"url": "http://easytier.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "http://easytier.shumengya.top/easytier.png",
|
|
||||||
"title": "EasyTier Dashboard",
|
|
||||||
"created_at": "2026-01-22T13:41:48.7994396+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:06.2605727+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cf72edea71c4cb2284c93",
|
|
||||||
"name": "wireguard面板",
|
|
||||||
"groups": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "170fab2239ad",
|
|
||||||
"url": "https://wireguard.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://wireguard.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-22T13:42:14.3258123+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:07.8283939+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cf746b16a83dc4d5e3d13",
|
|
||||||
"name": "萌芽Docker镜像仓库",
|
|
||||||
"groups": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "822329e621c2",
|
|
||||||
"url": "https://repo.docker.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"favicon": "https://repo.docker.shumengya.top/favicon.ico",
|
|
||||||
"title": "502 Bad Gateway",
|
|
||||||
"created_at": "2026-01-22T13:43:56.6460815+08:00",
|
|
||||||
"updated_at": "2026-01-26T11:56:40.972109897+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188cf74b2fa5da7c8e2b588e",
|
|
||||||
"name": "萌芽通知",
|
|
||||||
"groups": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "2f580f90f979",
|
|
||||||
"url": "https://notice.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://notice.shumengya.top/favicon.ico",
|
|
||||||
"title": "萌芽监控面板",
|
|
||||||
"created_at": "2026-01-22T13:44:15.9437687+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:09.4168617+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188d00db054cf7860dff3440",
|
|
||||||
"name": "萌芽Ping",
|
|
||||||
"groups": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "ac85b166ccc4",
|
|
||||||
"url": "https://ping.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://ping.shumengya.top/favicon.ico",
|
|
||||||
"title": "萌芽Ping - 网站监控面板",
|
|
||||||
"created_at": "2026-01-22T16:39:29.31324815+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:10.213788+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188d00edf571226f42ec8b20",
|
|
||||||
"name": "萌芽Ping后端API",
|
|
||||||
"groups": [
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "0bf0fe1bb9cc",
|
|
||||||
"url": "https://ping.api.shumengya.top",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"47.108.228.23"
|
|
||||||
],
|
|
||||||
"favicon": "https://ping.api.shumengya.top/favicon.ico",
|
|
||||||
"title": "萌芽Git仓库",
|
|
||||||
"created_at": "2026-01-22T16:40:50.651561641+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:11.3565622+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188e7f1a40cdc7bc3bba1817",
|
|
||||||
"name": "DNS查询[CloudFlare]",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "9af024b3d387",
|
|
||||||
"url": "https://cf-dns.smyhub.com/",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"188.114.97.0",
|
|
||||||
"188.114.96.0",
|
|
||||||
"2a06:98c1:3121::",
|
|
||||||
"2a06:98c1:3120::"
|
|
||||||
],
|
|
||||||
"favicon": "https://cf-assets.www.cloudflare.com/dzlvafdwdttg/6TaQ8Q7BDmdAFRoHpDCb82/8d9bc52a2ac5af100de3a9adcf99ffaa/security-shield-protection-2.svg",
|
|
||||||
"title": "DNS-over-HTTPS Resolver",
|
|
||||||
"created_at": "2026-01-27T13:24:14.3362887+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:12.1501803+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188e7f27a909fe247a2fcd15",
|
|
||||||
"name": "Github文件加速[CloudFlare]",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "b897ef0def67",
|
|
||||||
"url": "https://gh-proxy.smyhub.com/",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ip_addresses": [
|
|
||||||
"188.114.97.0",
|
|
||||||
"188.114.96.0",
|
|
||||||
"2a06:98c1:3121::",
|
|
||||||
"2a06:98c1:3120::"
|
|
||||||
],
|
|
||||||
"favicon": "https://gh-proxy.smyhub.com/favicon.ico",
|
|
||||||
"title": "GitHub 文件加速",
|
|
||||||
"created_at": "2026-01-27T13:25:11.9196401+08:00",
|
|
||||||
"updated_at": "2026-03-06T13:13:12.9476294+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188e7f33f087667c29da05f2",
|
|
||||||
"name": "FloppyBird[CloudFlare]",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "75a0cb223c2d",
|
|
||||||
"url": "https://floppy-bird.smyhub.com/",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"favicon": "https://floppy-bird.smyhub.com/favicon.ico",
|
|
||||||
"title": "Floppy Bird",
|
|
||||||
"created_at": "2026-01-27T13:26:04.6586487+08:00",
|
|
||||||
"updated_at": "2026-01-27T13:26:05.4749231+08:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "188e7f4cdd13a3bc80c68184",
|
|
||||||
"name": "别踩白方块[CloudFlare]",
|
|
||||||
"groups": [
|
|
||||||
"self-deploy"
|
|
||||||
],
|
|
||||||
"urls": [
|
|
||||||
{
|
|
||||||
"id": "9f11daaedba4",
|
|
||||||
"url": "https://floppy-bird.smyhub.com/",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"favicon": "https://floppy-bird.smyhub.com/favicon.ico",
|
|
||||||
"title": "Floppy Bird",
|
|
||||||
"created_at": "2026-01-27T13:27:51.7064775+08:00",
|
|
||||||
"updated_at": "2026-01-27T13:27:51.9331276+08:00"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# 生产:先在 Windows 运行 build-linux-amd64.bat 生成 dist/mengyaping-backend,再 docker compose build(镜像内不再编译 Go)
|
||||||
|
# 若需在服务器上用源码构建镜像:docker build -f Dockerfile.source -t mengyaping-backend:source .
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -16,11 +18,13 @@ services:
|
|||||||
# 服务器配置
|
# 服务器配置
|
||||||
- SERVER_PORT=8080
|
- SERVER_PORT=8080
|
||||||
- SERVER_HOST=0.0.0.0
|
- SERVER_HOST=0.0.0.0
|
||||||
# 监控配置
|
# 监控默认值(首次写入 MySQL monitor_kv;周期也可在管理后台改,须为 10/20/30/60/180/360 分钟之一)
|
||||||
- MONITOR_INTERVAL=5m
|
- MONITOR_INTERVAL=30m
|
||||||
- MONITOR_TIMEOUT=10s
|
- MONITOR_TIMEOUT=10s
|
||||||
- MONITOR_RETRY_COUNT=3
|
- MONITOR_RETRY_COUNT=3
|
||||||
- MONITOR_HISTORY_DAYS=7
|
- MONITOR_HISTORY_DAYS=7
|
||||||
|
# 若设置则覆盖数据库 monitor_kv 中的 admin_token(部署密文推荐用环境变量)
|
||||||
|
- ADMIN_TOKEN=shumengya520
|
||||||
networks:
|
networks:
|
||||||
- mengyaping-network
|
- mengyaping-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/gin-contrib/cors v1.7.6
|
github.com/gin-contrib/cors v1.7.6
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
gorm.io/datatypes v1.2.4
|
||||||
|
gorm.io/driver/mysql v1.5.7
|
||||||
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
@@ -16,8 +20,12 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
@@ -23,13 +25,34 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
@@ -38,6 +61,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
|
github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
|
||||||
|
github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -89,3 +116,16 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/datatypes v1.2.4 h1:uZmGAcK/QZ0uyfCuVg0VQY1ZmV9h1fuG0tMwKByO1z4=
|
||||||
|
gorm.io/datatypes v1.2.4/go.mod h1:f4BsLcFAX67szSv8svwLRjklArSHAvHLeE3pXAS5DZI=
|
||||||
|
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||||
|
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||||
|
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
||||||
|
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
||||||
|
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
|
||||||
|
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||||
|
gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
|
||||||
|
gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
|
||||||
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
|
|||||||
87
mengyaping-backend/handlers/settings.go
Normal file
87
mengyaping-backend/handlers/settings.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"mengyaping-backend/config"
|
||||||
|
"mengyaping-backend/models"
|
||||||
|
"mengyaping-backend/services"
|
||||||
|
"mengyaping-backend/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingsHandler 监控与系统配置(公开可读、管理端可写)
|
||||||
|
type SettingsHandler struct {
|
||||||
|
monitor *services.MonitorService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSettingsHandler 创建配置处理器
|
||||||
|
func NewSettingsHandler() *SettingsHandler {
|
||||||
|
return &SettingsHandler{
|
||||||
|
monitor: services.GetMonitorService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicMonitorSettings 公开:当前全局检测周期与可选值
|
||||||
|
func (h *SettingsHandler) GetPublicMonitorSettings(c *gin.Context) {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
|
Code: 0,
|
||||||
|
Message: "success",
|
||||||
|
Data: gin.H{
|
||||||
|
"interval_minutes": int(cfg.Monitor.Interval.Minutes()),
|
||||||
|
"allowed_interval_minutes": config.AllowedMonitorIntervalMinutes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminConfig 管理端:监控相关配置(只读字段供面板展示)
|
||||||
|
func (h *SettingsHandler) GetAdminConfig(c *gin.Context) {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
|
Code: 0,
|
||||||
|
Message: "success",
|
||||||
|
Data: gin.H{
|
||||||
|
"interval_minutes": int(cfg.Monitor.Interval.Minutes()),
|
||||||
|
"allowed_interval_minutes": config.AllowedMonitorIntervalMinutes,
|
||||||
|
"timeout_seconds": int(cfg.Monitor.Timeout.Seconds()),
|
||||||
|
"retry_count": cfg.Monitor.RetryCount,
|
||||||
|
"history_days": cfg.Monitor.HistoryDays,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type putAdminMonitorConfigRequest struct {
|
||||||
|
IntervalMinutes int `json:"interval_minutes" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutAdminMonitorConfig 更新全局检测周期并持久化、应用新调度
|
||||||
|
func (h *SettingsHandler) PutAdminMonitorConfig(c *gin.Context) {
|
||||||
|
var req putAdminMonitorConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||||
|
Code: 400,
|
||||||
|
Message: "参数错误: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.GetStorage().SetMonitorIntervalMinutes(req.IntervalMinutes); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||||
|
Code: 400,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.monitor.Reschedule()
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
|
Code: 0,
|
||||||
|
Message: "已更新检测周期",
|
||||||
|
Data: gin.H{
|
||||||
|
"interval_minutes": req.IntervalMinutes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,199 +1,267 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"mengyaping-backend/models"
|
"mengyaping-backend/models"
|
||||||
"mengyaping-backend/services"
|
"mengyaping-backend/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebsiteHandler 网站处理器
|
// WebsiteHandler 网站处理器
|
||||||
type WebsiteHandler struct {
|
type WebsiteHandler struct {
|
||||||
websiteService *services.WebsiteService
|
websiteService *services.WebsiteService
|
||||||
monitorService *services.MonitorService
|
monitorService *services.MonitorService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebsiteHandler 创建网站处理器
|
// NewWebsiteHandler 创建网站处理器
|
||||||
func NewWebsiteHandler() *WebsiteHandler {
|
func NewWebsiteHandler() *WebsiteHandler {
|
||||||
return &WebsiteHandler{
|
return &WebsiteHandler{
|
||||||
websiteService: services.NewWebsiteService(),
|
websiteService: services.NewWebsiteService(),
|
||||||
monitorService: services.GetMonitorService(),
|
monitorService: services.GetMonitorService(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWebsites 获取所有网站状态
|
// GetWebsites 获取所有网站状态
|
||||||
func (h *WebsiteHandler) GetWebsites(c *gin.Context) {
|
func (h *WebsiteHandler) GetWebsites(c *gin.Context) {
|
||||||
statuses := h.monitorService.GetAllWebsiteStatuses()
|
statuses := h.monitorService.GetAllWebsiteStatuses()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.APIResponse{
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
Code: 0,
|
Code: 0,
|
||||||
Message: "success",
|
Message: "success",
|
||||||
Data: statuses,
|
Data: statuses,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWebsite 获取单个网站状态
|
// GetWebsite 获取单个网站状态
|
||||||
func (h *WebsiteHandler) GetWebsite(c *gin.Context) {
|
func (h *WebsiteHandler) GetWebsite(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
status := h.monitorService.GetWebsiteStatus(id)
|
status := h.monitorService.GetWebsiteStatus(id)
|
||||||
if status == nil {
|
if status == nil {
|
||||||
c.JSON(http.StatusNotFound, models.APIResponse{
|
c.JSON(http.StatusNotFound, models.APIResponse{
|
||||||
Code: 404,
|
Code: 404,
|
||||||
Message: "网站不存在",
|
Message: "网站不存在",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.APIResponse{
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
Code: 0,
|
Code: 0,
|
||||||
Message: "success",
|
Message: "success",
|
||||||
Data: status,
|
Data: status,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateWebsite 创建网站
|
// CreateWebsite 创建网站
|
||||||
func (h *WebsiteHandler) CreateWebsite(c *gin.Context) {
|
func (h *WebsiteHandler) CreateWebsite(c *gin.Context) {
|
||||||
var req models.CreateWebsiteRequest
|
var req models.CreateWebsiteRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||||
Code: 400,
|
Code: 400,
|
||||||
Message: "参数错误: " + err.Error(),
|
Message: "参数错误: " + err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
website, err := h.websiteService.CreateWebsite(req)
|
website, err := h.websiteService.CreateWebsite(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||||
Code: 500,
|
Code: 500,
|
||||||
Message: "创建失败: " + err.Error(),
|
Message: "创建失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.APIResponse{
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
Code: 0,
|
Code: 0,
|
||||||
Message: "创建成功",
|
Message: "创建成功",
|
||||||
Data: website,
|
Data: website,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateWebsite 更新网站
|
// UpdateWebsite 更新网站
|
||||||
func (h *WebsiteHandler) UpdateWebsite(c *gin.Context) {
|
func (h *WebsiteHandler) UpdateWebsite(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
var req models.UpdateWebsiteRequest
|
var req models.UpdateWebsiteRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||||
Code: 400,
|
Code: 400,
|
||||||
Message: "参数错误: " + err.Error(),
|
Message: "参数错误: " + err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
website, err := h.websiteService.UpdateWebsite(id, req)
|
website, err := h.websiteService.UpdateWebsite(id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||||
Code: 500,
|
Code: 500,
|
||||||
Message: "更新失败: " + err.Error(),
|
Message: "更新失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if website == nil {
|
if website == nil {
|
||||||
c.JSON(http.StatusNotFound, models.APIResponse{
|
c.JSON(http.StatusNotFound, models.APIResponse{
|
||||||
Code: 404,
|
Code: 404,
|
||||||
Message: "网站不存在",
|
Message: "网站不存在",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.APIResponse{
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
Code: 0,
|
Code: 0,
|
||||||
Message: "更新成功",
|
Message: "更新成功",
|
||||||
Data: website,
|
Data: website,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteWebsite 删除网站
|
// DeleteWebsite 删除网站
|
||||||
func (h *WebsiteHandler) DeleteWebsite(c *gin.Context) {
|
func (h *WebsiteHandler) DeleteWebsite(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
if err := h.websiteService.DeleteWebsite(id); err != nil {
|
if err := h.websiteService.DeleteWebsite(id); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||||
Code: 500,
|
Code: 500,
|
||||||
Message: "删除失败: " + err.Error(),
|
Message: "删除失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.APIResponse{
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
Code: 0,
|
Code: 0,
|
||||||
Message: "删除成功",
|
Message: "删除成功",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckWebsiteNow 立即检测网站
|
// CheckWebsiteNow 立即检测网站
|
||||||
func (h *WebsiteHandler) CheckWebsiteNow(c *gin.Context) {
|
func (h *WebsiteHandler) CheckWebsiteNow(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
website := h.websiteService.GetWebsite(id)
|
website := h.websiteService.GetWebsite(id)
|
||||||
if website == nil {
|
if website == nil {
|
||||||
c.JSON(http.StatusNotFound, models.APIResponse{
|
c.JSON(http.StatusNotFound, models.APIResponse{
|
||||||
Code: 404,
|
Code: 404,
|
||||||
Message: "网站不存在",
|
Message: "网站不存在",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.monitorService.CheckWebsiteNow(id)
|
h.monitorService.CheckWebsiteNow(id)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.APIResponse{
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
Code: 0,
|
Code: 0,
|
||||||
Message: "检测任务已提交",
|
Message: "检测任务已提交",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGroups 获取所有分组
|
// GetGroups 获取所有分组
|
||||||
func (h *WebsiteHandler) GetGroups(c *gin.Context) {
|
func (h *WebsiteHandler) GetGroups(c *gin.Context) {
|
||||||
groups := h.websiteService.GetGroups()
|
groups := h.websiteService.GetGroups()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.APIResponse{
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
Code: 0,
|
Code: 0,
|
||||||
Message: "success",
|
Message: "success",
|
||||||
Data: groups,
|
Data: groups,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddGroup 添加分组
|
// GetGroup 获取单个分组
|
||||||
func (h *WebsiteHandler) AddGroup(c *gin.Context) {
|
func (h *WebsiteHandler) GetGroup(c *gin.Context) {
|
||||||
var req struct {
|
id := c.Param("id")
|
||||||
Name string `json:"name" binding:"required"`
|
g := h.websiteService.GetGroup(id)
|
||||||
}
|
if g == nil {
|
||||||
|
c.JSON(http.StatusNotFound, models.APIResponse{
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
Code: 404,
|
||||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
Message: "分类不存在",
|
||||||
Code: 400,
|
})
|
||||||
Message: "参数错误: " + err.Error(),
|
return
|
||||||
})
|
}
|
||||||
return
|
|
||||||
}
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
|
Code: 0,
|
||||||
group, err := h.websiteService.AddGroup(req.Name)
|
Message: "success",
|
||||||
if err != nil {
|
Data: g,
|
||||||
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
})
|
||||||
Code: 500,
|
}
|
||||||
Message: "添加失败: " + err.Error(),
|
|
||||||
})
|
// UpdateGroup 更新分组
|
||||||
return
|
func (h *WebsiteHandler) UpdateGroup(c *gin.Context) {
|
||||||
}
|
id := c.Param("id")
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.APIResponse{
|
var req struct {
|
||||||
Code: 0,
|
Name string `json:"name" binding:"required"`
|
||||||
Message: "添加成功",
|
}
|
||||||
Data: group,
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
})
|
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||||
}
|
Code: 400,
|
||||||
|
Message: "参数错误: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := h.websiteService.UpdateGroup(id, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||||
|
Code: 400,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
|
Code: 0,
|
||||||
|
Message: "更新成功",
|
||||||
|
Data: group,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroup 删除分组
|
||||||
|
func (h *WebsiteHandler) DeleteGroup(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
if err := h.websiteService.DeleteGroup(id); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||||
|
Code: 400,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
|
Code: 0,
|
||||||
|
Message: "删除成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddGroup 添加分组
|
||||||
|
func (h *WebsiteHandler) AddGroup(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||||
|
Code: 400,
|
||||||
|
Message: "参数错误: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := h.websiteService.AddGroup(req.Name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.APIResponse{
|
||||||
|
Code: 500,
|
||||||
|
Message: "添加失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.APIResponse{
|
||||||
|
Code: 0,
|
||||||
|
Message: "添加成功",
|
||||||
|
Data: group,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,47 +1,50 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"mengyaping-backend/config"
|
"mengyaping-backend/config"
|
||||||
"mengyaping-backend/router"
|
"mengyaping-backend/router"
|
||||||
"mengyaping-backend/services"
|
"mengyaping-backend/services"
|
||||||
)
|
"mengyaping-backend/storage"
|
||||||
|
)
|
||||||
func main() {
|
|
||||||
// 获取配置
|
func main() {
|
||||||
cfg := config.GetConfig()
|
config.GetConfig()
|
||||||
|
// 连接 MySQL 并从 monitor_kv 加载应用配置(须在监听与监控协程使用配置之前)
|
||||||
// 确保数据目录存在
|
storage.GetStorage()
|
||||||
os.MkdirAll(cfg.DataPath, 0755)
|
cfg := config.GetConfig()
|
||||||
|
if err := os.MkdirAll(cfg.DataPath, 0755); err != nil {
|
||||||
// 启动监控服务
|
log.Printf("创建数据目录 %s: %v", cfg.DataPath, err)
|
||||||
monitorService := services.GetMonitorService()
|
}
|
||||||
go monitorService.Start()
|
|
||||||
|
// 启动监控服务
|
||||||
// 设置路由
|
monitorService := services.GetMonitorService()
|
||||||
r := router.SetupRouter()
|
go monitorService.Start()
|
||||||
|
|
||||||
// 优雅关闭
|
// 设置路由
|
||||||
go func() {
|
r := router.SetupRouter()
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
// 优雅关闭
|
||||||
<-sigCh
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
log.Println("正在关闭服务...")
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
monitorService.Stop()
|
<-sigCh
|
||||||
os.Exit(0)
|
|
||||||
}()
|
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 {
|
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
|
||||||
log.Fatalf("服务器启动失败: %v", err)
|
log.Printf("🌱 萌芽Ping 监控服务已启动,监听地址: %s\n", addr)
|
||||||
}
|
|
||||||
}
|
if err := r.Run(addr); err != nil {
|
||||||
|
log.Fatalf("服务器启动失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
43
mengyaping-backend/middleware/admin.go
Normal file
43
mengyaping-backend/middleware/admin.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"mengyaping-backend/models"
|
||||||
|
"mengyaping-backend/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminAuth 校验管理员令牌:环境变量 ADMIN_TOKEN 优先,否则使用 MySQL monitor_kv 中的 admin_token(请求头 X-Admin-Token 或 Authorization: Bearer)
|
||||||
|
func AdminAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
expected := storage.GetStorage().EffectiveAdminToken()
|
||||||
|
if expected == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, models.APIResponse{
|
||||||
|
Code: 503,
|
||||||
|
Message: "未配置管理员令牌:请在数据库 monitor_kv 写入 admin_token,或设置环境变量 ADMIN_TOKEN",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.TrimSpace(c.GetHeader("X-Admin-Token"))
|
||||||
|
if got == "" {
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if len(auth) >= len(prefix) && strings.EqualFold(auth[:len(prefix)], prefix) {
|
||||||
|
got = strings.TrimSpace(auth[len(prefix):])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got == "" || got != expected {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIResponse{
|
||||||
|
Code: 401,
|
||||||
|
Message: "无效的管理员令牌",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,113 +1,117 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Website 网站信息
|
// Website 网站信息
|
||||||
type Website struct {
|
type Website struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"` // 网站名称
|
Name string `json:"name"` // 网站名称
|
||||||
Groups []string `json:"groups"` // 所属分组列表(支持多分组)
|
Groups []string `json:"groups"` // 所属分组列表(支持多分组)
|
||||||
Group string `json:"group,omitempty"` // 已废弃,仅用于旧数据兼容
|
Group string `json:"group,omitempty"` // 已废弃,仅用于旧数据兼容
|
||||||
URLs []URLInfo `json:"urls"` // 网站访问地址列表
|
URLs []URLInfo `json:"urls"` // 网站访问地址列表
|
||||||
IPAddresses []string `json:"ip_addresses,omitempty"` // 域名解析的IP地址
|
IPAddresses []string `json:"ip_addresses,omitempty"` // 域名解析的IP地址
|
||||||
Favicon string `json:"favicon"` // 网站图标URL
|
Favicon string `json:"favicon"` // 网站图标URL
|
||||||
Title string `json:"title"` // 网站标题
|
Title string `json:"title"` // 网站标题
|
||||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// URLInfo 单个URL的信息
|
// URLInfo 单个URL的信息
|
||||||
type URLInfo struct {
|
type URLInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
URL string `json:"url"` // 访问地址
|
URL string `json:"url"` // 访问地址
|
||||||
Remark string `json:"remark"` // 备注说明
|
Remark string `json:"remark"` // 备注说明
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonitorRecord 监控记录
|
// MonitorRecord 监控记录
|
||||||
type MonitorRecord struct {
|
type MonitorRecord struct {
|
||||||
WebsiteID string `json:"website_id"`
|
WebsiteID string `json:"website_id"`
|
||||||
URLID string `json:"url_id"`
|
URLID string `json:"url_id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
StatusCode int `json:"status_code"` // HTTP状态码
|
StatusCode int `json:"status_code"` // HTTP状态码
|
||||||
Latency int64 `json:"latency"` // 延迟(毫秒)
|
Latency int64 `json:"latency"` // 延迟(毫秒)
|
||||||
IsUp bool `json:"is_up"` // 是否可访问
|
IsUp bool `json:"is_up"` // 是否可访问
|
||||||
Error string `json:"error"` // 错误信息
|
Error string `json:"error"` // 错误信息
|
||||||
CheckedAt time.Time `json:"checked_at"` // 检测时间
|
CheckedAt time.Time `json:"checked_at"` // 检测时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebsiteStatus 网站状态(用于前端展示)
|
// WebsiteStatus 网站状态(用于前端展示)
|
||||||
type WebsiteStatus struct {
|
type WebsiteStatus struct {
|
||||||
Website Website `json:"website"`
|
Website Website `json:"website"`
|
||||||
URLStatuses []URLStatus `json:"url_statuses"`
|
IsIntranet bool `json:"is_intranet"` // 主 URL 为内网 IP:不解析公网 DNS、不展示解析 IP,用内网图标
|
||||||
DailyHistory []DailyStats `json:"daily_history"` // 90天逐日统计
|
URLStatuses []URLStatus `json:"url_statuses"`
|
||||||
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
|
DailyHistory []DailyStats `json:"daily_history"` // 90天逐日统计
|
||||||
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
|
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
|
||||||
Uptime90d float64 `json:"uptime_90d"` // 90天可用率
|
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
|
||||||
LastChecked time.Time `json:"last_checked"` // 最后检测时间
|
Uptime90d float64 `json:"uptime_90d"` // 90天可用率
|
||||||
}
|
LastChecked time.Time `json:"last_checked"` // 最后检测时间
|
||||||
|
}
|
||||||
// URLStatus 单个URL的状态
|
|
||||||
type URLStatus struct {
|
// URLStatus 单个URL的状态
|
||||||
URLInfo URLInfo `json:"url_info"`
|
type URLStatus struct {
|
||||||
CurrentState MonitorRecord `json:"current_state"` // 当前状态
|
URLInfo URLInfo `json:"url_info"`
|
||||||
History24h []MonitorRecord `json:"history_24h"` // 24小时历史
|
CurrentState MonitorRecord `json:"current_state"` // 当前状态
|
||||||
History7d []HourlyStats `json:"history_7d"` // 7天按小时统计
|
History24h []MonitorRecord `json:"history_24h"` // 24小时历史
|
||||||
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
|
History7d []HourlyStats `json:"history_7d"` // 7天按小时统计
|
||||||
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
|
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
|
||||||
AvgLatency int64 `json:"avg_latency"` // 平均延迟
|
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
|
||||||
}
|
AvgLatency int64 `json:"avg_latency"` // 平均延迟
|
||||||
|
}
|
||||||
// HourlyStats 每小时统计
|
|
||||||
type HourlyStats struct {
|
// HourlyStats 每小时统计
|
||||||
Hour time.Time `json:"hour"`
|
type HourlyStats struct {
|
||||||
TotalCount int `json:"total_count"`
|
Hour time.Time `json:"hour"`
|
||||||
UpCount int `json:"up_count"`
|
TotalCount int `json:"total_count"`
|
||||||
AvgLatency int64 `json:"avg_latency"`
|
UpCount int `json:"up_count"`
|
||||||
Uptime float64 `json:"uptime"`
|
AvgLatency int64 `json:"avg_latency"`
|
||||||
}
|
Uptime float64 `json:"uptime"`
|
||||||
|
}
|
||||||
// DailyStats 每日统计
|
|
||||||
type DailyStats struct {
|
// DailyStats 每日统计
|
||||||
Date time.Time `json:"date"`
|
type DailyStats struct {
|
||||||
TotalCount int `json:"total_count"`
|
Date time.Time `json:"date"`
|
||||||
UpCount int `json:"up_count"`
|
TotalCount int `json:"total_count"`
|
||||||
AvgLatency int64 `json:"avg_latency"`
|
UpCount int `json:"up_count"`
|
||||||
Uptime float64 `json:"uptime"`
|
AvgLatency int64 `json:"avg_latency"`
|
||||||
}
|
Uptime float64 `json:"uptime"`
|
||||||
|
}
|
||||||
// Group 分组
|
|
||||||
type Group struct {
|
// Group 分组
|
||||||
ID string `json:"id"`
|
type Group struct {
|
||||||
Name string `json:"name"`
|
ID string `json:"id"`
|
||||||
}
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
// DefaultGroups 默认分组
|
|
||||||
var DefaultGroups = []Group{
|
// DefaultGroups 默认分组
|
||||||
{ID: "normal", Name: "普通网站"},
|
var DefaultGroups = []Group{
|
||||||
{ID: "admin", Name: "管理员网站"},
|
{ID: "normal", Name: "普通网站"},
|
||||||
}
|
{ID: "admin", Name: "管理员网站"},
|
||||||
|
}
|
||||||
// CreateWebsiteRequest 创建网站请求
|
|
||||||
type CreateWebsiteRequest struct {
|
// DefaultIntranetFavicon 直填内网站点的列表图标(静态 SVG,不依赖公网 favicon 抓取)
|
||||||
Name string `json:"name" binding:"required"`
|
const DefaultIntranetFavicon = "https://api.iconify.design/mdi/lan-connect.svg?color=%23059669"
|
||||||
Groups []string `json:"groups" binding:"required,min=1"`
|
|
||||||
Group string `json:"group"`
|
// CreateWebsiteRequest 创建网站请求
|
||||||
URLs []string `json:"urls" binding:"required,min=1"`
|
type CreateWebsiteRequest struct {
|
||||||
}
|
Name string `json:"name" binding:"required"`
|
||||||
|
Groups []string `json:"groups" binding:"required,min=1"`
|
||||||
// UpdateWebsiteRequest 更新网站请求
|
Group string `json:"group"`
|
||||||
type UpdateWebsiteRequest struct {
|
URLs []string `json:"urls" binding:"required,min=1"`
|
||||||
Name string `json:"name"`
|
}
|
||||||
Groups []string `json:"groups"`
|
|
||||||
Group string `json:"group"`
|
// UpdateWebsiteRequest 更新网站请求
|
||||||
URLs []string `json:"urls"`
|
type UpdateWebsiteRequest struct {
|
||||||
}
|
Name string `json:"name"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
// APIResponse API响应
|
Group string `json:"group"`
|
||||||
type APIResponse struct {
|
URLs []string `json:"urls"`
|
||||||
Code int `json:"code"`
|
}
|
||||||
Message string `json:"message"`
|
|
||||||
Data interface{} `json:"data,omitempty"`
|
// APIResponse API响应
|
||||||
}
|
type APIResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,51 +1,66 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"mengyaping-backend/handlers"
|
"mengyaping-backend/handlers"
|
||||||
)
|
"mengyaping-backend/middleware"
|
||||||
|
)
|
||||||
// SetupRouter 设置路由
|
|
||||||
func SetupRouter() *gin.Engine {
|
// SetupRouter 设置路由
|
||||||
r := gin.Default()
|
func SetupRouter() *gin.Engine {
|
||||||
|
r := gin.Default()
|
||||||
// CORS配置
|
|
||||||
r.Use(cors.New(cors.Config{
|
// CORS配置
|
||||||
AllowOrigins: []string{"*"},
|
r.Use(cors.New(cors.Config{
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowOrigins: []string{"*"},
|
||||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
ExposeHeaders: []string{"Content-Length"},
|
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Admin-Token"},
|
||||||
AllowCredentials: true,
|
ExposeHeaders: []string{"Content-Length"},
|
||||||
}))
|
AllowCredentials: true,
|
||||||
|
}))
|
||||||
// 创建处理器
|
|
||||||
websiteHandler := handlers.NewWebsiteHandler()
|
// 创建处理器
|
||||||
|
websiteHandler := handlers.NewWebsiteHandler()
|
||||||
// API路由组
|
settingsHandler := handlers.NewSettingsHandler()
|
||||||
api := r.Group("/api")
|
|
||||||
{
|
// API路由组
|
||||||
// 健康检查
|
api := r.Group("/api")
|
||||||
api.GET("/health", func(c *gin.Context) {
|
{
|
||||||
c.JSON(200, gin.H{
|
// 健康检查
|
||||||
"status": "ok",
|
api.GET("/health", func(c *gin.Context) {
|
||||||
"message": "服务运行正常",
|
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.GET("/monitor/settings", settingsHandler.GetPublicMonitorSettings)
|
||||||
api.PUT("/websites/:id", websiteHandler.UpdateWebsite)
|
|
||||||
api.DELETE("/websites/:id", websiteHandler.DeleteWebsite)
|
// 网站 / 分组 — 读操作公开
|
||||||
api.POST("/websites/:id/check", websiteHandler.CheckWebsiteNow)
|
api.GET("/websites", websiteHandler.GetWebsites)
|
||||||
|
api.GET("/websites/:id", websiteHandler.GetWebsite)
|
||||||
// 分组相关
|
api.GET("/groups", websiteHandler.GetGroups)
|
||||||
api.GET("/groups", websiteHandler.GetGroups)
|
api.GET("/groups/:id", websiteHandler.GetGroup)
|
||||||
api.POST("/groups", websiteHandler.AddGroup)
|
|
||||||
}
|
// 管理:写操作需 ADMIN_TOKEN
|
||||||
|
admin := api.Group("")
|
||||||
return r
|
admin.Use(middleware.AdminAuth())
|
||||||
}
|
{
|
||||||
|
admin.POST("/websites", websiteHandler.CreateWebsite)
|
||||||
|
admin.PUT("/websites/:id", websiteHandler.UpdateWebsite)
|
||||||
|
admin.DELETE("/websites/:id", websiteHandler.DeleteWebsite)
|
||||||
|
admin.POST("/websites/:id/check", websiteHandler.CheckWebsiteNow)
|
||||||
|
admin.POST("/groups", websiteHandler.AddGroup)
|
||||||
|
admin.PUT("/groups/:id", websiteHandler.UpdateGroup)
|
||||||
|
admin.DELETE("/groups/:id", websiteHandler.DeleteGroup)
|
||||||
|
|
||||||
|
admin.GET("/admin/config", settingsHandler.GetAdminConfig)
|
||||||
|
admin.PUT("/admin/config", settingsHandler.PutAdminMonitorConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|||||||
19
mengyaping-backend/schema/mysql.sql
Normal file
19
mengyaping-backend/schema/mysql.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- 萌芽 Ping — MySQL 8+ 表结构说明(应用启动时 GORM AutoMigrate 会自动建表;本文件便于 DBA 审阅或手工执行)
|
||||||
|
-- 字符集务必 utf8mb4
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS mengyaping
|
||||||
|
DEFAULT CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE mengyaping;
|
||||||
|
|
||||||
|
-- monitor_groups 网站分类
|
||||||
|
-- monitor_websites 监控站点主档(ip_addresses 为 JSON 数组)
|
||||||
|
-- monitor_website_urls 每个站点下多监控 URL
|
||||||
|
-- monitor_website_groups 站点与分类多对多
|
||||||
|
-- monitor_checks HTTP 探活历史(按保留天数定期清理)
|
||||||
|
|
||||||
|
-- monitor_kv 键值配置:admin_token;cfg_server / cfg_monitor / cfg_data_path / cfg_database(JSON)
|
||||||
|
-- 与下列 GORM 模型一致:
|
||||||
|
-- MonitorGroup, MonitorWebsite, MonitorWebsiteURL, MonitorWebsiteGroup,
|
||||||
|
-- MonitorProbeLatest, MonitorProbeHour, MonitorProbeDay, MonitorKV
|
||||||
@@ -1,418 +1,488 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sync"
|
||||||
"sync"
|
"time"
|
||||||
"time"
|
|
||||||
|
"mengyaping-backend/config"
|
||||||
"mengyaping-backend/config"
|
"mengyaping-backend/models"
|
||||||
"mengyaping-backend/models"
|
"mengyaping-backend/storage"
|
||||||
"mengyaping-backend/storage"
|
"mengyaping-backend/utils"
|
||||||
"mengyaping-backend/utils"
|
)
|
||||||
)
|
|
||||||
|
// MonitorService 监控服务
|
||||||
// MonitorService 监控服务
|
type MonitorService struct {
|
||||||
type MonitorService struct {
|
httpClient *utils.HTTPClient
|
||||||
httpClient *utils.HTTPClient
|
storage *storage.Storage
|
||||||
storage *storage.Storage
|
stopCh chan struct{}
|
||||||
stopCh chan struct{}
|
rescheduleCh chan struct{}
|
||||||
running bool
|
running bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
monitorService *MonitorService
|
monitorService *MonitorService
|
||||||
monitorOnce sync.Once
|
monitorOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetMonitorService 获取监控服务单例
|
// GetMonitorService 获取监控服务单例
|
||||||
func GetMonitorService() *MonitorService {
|
func GetMonitorService() *MonitorService {
|
||||||
monitorOnce.Do(func() {
|
monitorOnce.Do(func() {
|
||||||
cfg := config.GetConfig()
|
st := storage.GetStorage()
|
||||||
monitorService = &MonitorService{
|
cfg := config.GetConfig()
|
||||||
httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout),
|
monitorService = &MonitorService{
|
||||||
storage: storage.GetStorage(),
|
httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout),
|
||||||
stopCh: make(chan struct{}),
|
storage: st,
|
||||||
}
|
stopCh: make(chan struct{}),
|
||||||
})
|
rescheduleCh: make(chan struct{}, 1),
|
||||||
return monitorService
|
}
|
||||||
}
|
})
|
||||||
|
return monitorService
|
||||||
// Start 启动监控服务
|
}
|
||||||
func (s *MonitorService) Start() {
|
|
||||||
s.mu.Lock()
|
// Start 启动监控服务
|
||||||
if s.running {
|
func (s *MonitorService) Start() {
|
||||||
s.mu.Unlock()
|
s.mu.Lock()
|
||||||
return
|
if s.running {
|
||||||
}
|
s.mu.Unlock()
|
||||||
s.running = true
|
return
|
||||||
s.mu.Unlock()
|
}
|
||||||
|
s.running = true
|
||||||
log.Println("监控服务已启动")
|
s.mu.Unlock()
|
||||||
|
|
||||||
// 立即执行一次检测
|
log.Println("监控服务已启动")
|
||||||
go s.checkAll()
|
go s.runLoop()
|
||||||
|
}
|
||||||
// 定时检测
|
|
||||||
cfg := config.GetConfig()
|
// Reschedule 在检测间隔配置变更后唤醒主循环以使用新周期(非阻塞)
|
||||||
ticker := time.NewTicker(cfg.Monitor.Interval)
|
func (s *MonitorService) Reschedule() {
|
||||||
defer ticker.Stop()
|
select {
|
||||||
|
case s.rescheduleCh <- struct{}{}:
|
||||||
for {
|
default:
|
||||||
select {
|
}
|
||||||
case <-ticker.C:
|
}
|
||||||
go s.checkAll()
|
|
||||||
case <-s.stopCh:
|
func (s *MonitorService) runLoop() {
|
||||||
log.Println("监控服务已停止")
|
go s.checkAll()
|
||||||
return
|
|
||||||
}
|
d := config.GetConfig().Monitor.Interval
|
||||||
}
|
if d <= 0 {
|
||||||
}
|
d = 30 * time.Minute
|
||||||
|
}
|
||||||
// Stop 停止监控服务
|
timer := time.NewTimer(d)
|
||||||
func (s *MonitorService) Stop() {
|
defer timer.Stop()
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
for {
|
||||||
|
select {
|
||||||
if s.running {
|
case <-s.stopCh:
|
||||||
close(s.stopCh)
|
if !timer.Stop() {
|
||||||
s.running = false
|
select {
|
||||||
}
|
case <-timer.C:
|
||||||
}
|
default:
|
||||||
|
}
|
||||||
// checkAll 检查所有网站(错峰执行,避免并发暴涨)
|
}
|
||||||
func (s *MonitorService) checkAll() {
|
log.Println("监控服务已停止")
|
||||||
websites := s.storage.GetWebsites()
|
return
|
||||||
semaphore := make(chan struct{}, 3) // 最多 3 个并发检测
|
case <-s.rescheduleCh:
|
||||||
|
if !timer.Stop() {
|
||||||
for i, website := range websites {
|
select {
|
||||||
// 每个网站之间间隔 1 秒,把检测分散开
|
case <-timer.C:
|
||||||
if i > 0 {
|
default:
|
||||||
time.Sleep(1 * time.Second)
|
}
|
||||||
}
|
}
|
||||||
|
d = config.GetConfig().Monitor.Interval
|
||||||
var wg sync.WaitGroup
|
if d <= 0 {
|
||||||
for _, urlInfo := range website.URLs {
|
d = 30 * time.Minute
|
||||||
wg.Add(1)
|
}
|
||||||
go func(w models.Website, u models.URLInfo) {
|
timer.Reset(d)
|
||||||
defer wg.Done()
|
case <-timer.C:
|
||||||
semaphore <- struct{}{}
|
go s.checkAll()
|
||||||
defer func() { <-semaphore }()
|
d = config.GetConfig().Monitor.Interval
|
||||||
s.checkURL(w, u)
|
if d <= 0 {
|
||||||
}(website, urlInfo)
|
d = 30 * time.Minute
|
||||||
}
|
}
|
||||||
wg.Wait()
|
timer.Reset(d)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// 检测完毕后,逐个解析 DNS
|
}
|
||||||
s.resolveAllWebsiteIPs(websites)
|
|
||||||
|
// Stop 停止监控服务
|
||||||
// 保存记录
|
func (s *MonitorService) Stop() {
|
||||||
s.storage.SaveAll()
|
s.mu.Lock()
|
||||||
log.Printf("本轮检测完成,共 %d 个网站", len(websites))
|
defer s.mu.Unlock()
|
||||||
}
|
|
||||||
|
if s.running {
|
||||||
// resolveAllWebsiteIPs 逐个解析所有网站域名 IP(每次都刷新)
|
close(s.stopCh)
|
||||||
func (s *MonitorService) resolveAllWebsiteIPs(websites []models.Website) {
|
s.running = false
|
||||||
for i, website := range websites {
|
}
|
||||||
if len(website.URLs) == 0 {
|
}
|
||||||
continue
|
|
||||||
}
|
// checkAll 检查所有网站(错峰执行,避免并发暴涨)
|
||||||
if i > 0 {
|
func (s *MonitorService) checkAll() {
|
||||||
time.Sleep(500 * time.Millisecond)
|
websites := s.storage.GetWebsites()
|
||||||
}
|
semaphore := make(chan struct{}, 3) // 最多 3 个并发检测
|
||||||
s.resolveWebsiteIP(website)
|
|
||||||
}
|
for i, website := range websites {
|
||||||
}
|
// 每个网站之间间隔 1 秒,把检测分散开
|
||||||
|
if i > 0 {
|
||||||
// resolveWebsiteIP 解析单个网站的域名 IP
|
time.Sleep(1 * time.Second)
|
||||||
func (s *MonitorService) resolveWebsiteIP(website models.Website) {
|
}
|
||||||
if len(website.URLs) == 0 {
|
|
||||||
return
|
var wg sync.WaitGroup
|
||||||
}
|
for _, urlInfo := range website.URLs {
|
||||||
|
wg.Add(1)
|
||||||
ips, err := utils.ResolveDomainIPs(website.URLs[0].URL)
|
go func(w models.Website, u models.URLInfo) {
|
||||||
if err != nil {
|
defer wg.Done()
|
||||||
log.Printf("DNS解析失败 [%s]: %v", website.Name, err)
|
semaphore <- struct{}{}
|
||||||
return
|
defer func() { <-semaphore }()
|
||||||
}
|
s.checkURL(w, u)
|
||||||
|
}(website, urlInfo)
|
||||||
if len(ips) == 0 {
|
}
|
||||||
return
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
w := s.storage.GetWebsite(website.ID)
|
// 检测完毕后,逐个解析 DNS
|
||||||
if w == nil {
|
s.resolveAllWebsiteIPs(websites)
|
||||||
return
|
|
||||||
}
|
// 保存记录
|
||||||
|
s.storage.SaveAll()
|
||||||
w.IPAddresses = ips
|
log.Printf("本轮检测完成,共 %d 个网站", len(websites))
|
||||||
w.UpdatedAt = time.Now()
|
}
|
||||||
s.storage.UpdateWebsite(*w)
|
|
||||||
log.Printf("DNS解析 [%s] → %v", website.Name, ips)
|
// resolveAllWebsiteIPs 逐个解析所有网站域名 IP(每次都刷新)
|
||||||
}
|
func (s *MonitorService) resolveAllWebsiteIPs(websites []models.Website) {
|
||||||
|
for i, website := range websites {
|
||||||
// checkURL 检查单个URL(带重试)
|
if len(website.URLs) == 0 {
|
||||||
func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
|
continue
|
||||||
cfg := config.GetConfig()
|
}
|
||||||
maxRetries := cfg.Monitor.RetryCount
|
if i > 0 {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
var result utils.CheckResult
|
}
|
||||||
|
s.resolveWebsiteIP(website)
|
||||||
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)
|
// resolveWebsiteIP 解析单个网站的域名 IP
|
||||||
}
|
func (s *MonitorService) resolveWebsiteIP(website models.Website) {
|
||||||
|
if len(website.URLs) == 0 {
|
||||||
result = s.httpClient.CheckWebsiteStatus(urlInfo.URL)
|
return
|
||||||
if result.Error == nil && utils.IsSuccessStatus(result.StatusCode) {
|
}
|
||||||
break
|
|
||||||
}
|
if utils.IsIntranetURL(website.URLs[0].URL) {
|
||||||
}
|
w := s.storage.GetWebsite(website.ID)
|
||||||
|
if w != nil && len(w.IPAddresses) > 0 {
|
||||||
record := models.MonitorRecord{
|
w.IPAddresses = nil
|
||||||
WebsiteID: website.ID,
|
w.UpdatedAt = time.Now()
|
||||||
URLID: urlInfo.ID,
|
s.storage.UpdateWebsite(*w)
|
||||||
URL: urlInfo.URL,
|
}
|
||||||
StatusCode: result.StatusCode,
|
return
|
||||||
Latency: result.Latency.Milliseconds(),
|
}
|
||||||
IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode),
|
|
||||||
CheckedAt: time.Now(),
|
ips, err := utils.ResolveDomainIPs(website.URLs[0].URL)
|
||||||
}
|
if err != nil {
|
||||||
|
log.Printf("DNS解析失败 [%s]: %v", website.Name, err)
|
||||||
if result.Error != nil {
|
return
|
||||||
record.Error = result.Error.Error()
|
}
|
||||||
}
|
|
||||||
|
if len(ips) == 0 {
|
||||||
s.storage.AddRecord(record)
|
return
|
||||||
|
}
|
||||||
// 仅当网站无标题时才做完整检测来获取元数据
|
|
||||||
if record.IsUp && website.Title == "" {
|
w := s.storage.GetWebsite(website.ID)
|
||||||
fullResult := s.httpClient.CheckWebsite(urlInfo.URL)
|
if w == nil {
|
||||||
if fullResult.Title != "" {
|
return
|
||||||
w := s.storage.GetWebsite(website.ID)
|
}
|
||||||
if w != nil {
|
|
||||||
w.Title = fullResult.Title
|
w.IPAddresses = ips
|
||||||
w.UpdatedAt = time.Now()
|
w.UpdatedAt = time.Now()
|
||||||
s.storage.UpdateWebsite(*w)
|
s.storage.UpdateWebsite(*w)
|
||||||
}
|
log.Printf("DNS解析 [%s] → %v", website.Name, ips)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// checkURL 检查单个URL(带重试)
|
||||||
log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v",
|
func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
|
||||||
website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp)
|
cfg := config.GetConfig()
|
||||||
}
|
maxRetries := cfg.Monitor.RetryCount
|
||||||
|
|
||||||
// CheckWebsiteNow 立即检查指定网站(状态 + DNS,等待完成后保存)
|
var result utils.CheckResult
|
||||||
func (s *MonitorService) CheckWebsiteNow(websiteID string) {
|
|
||||||
website := s.storage.GetWebsite(websiteID)
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
if website == nil {
|
if attempt > 0 {
|
||||||
return
|
time.Sleep(time.Duration(attempt) * 2 * time.Second)
|
||||||
}
|
log.Printf("重试 [%s] %s - 第 %d 次重试", website.Name, urlInfo.URL, attempt)
|
||||||
|
}
|
||||||
// 逐个检测该网站的所有 URL
|
|
||||||
for _, urlInfo := range website.URLs {
|
result = s.httpClient.CheckWebsiteStatus(urlInfo.URL)
|
||||||
s.checkURL(*website, urlInfo)
|
if result.Error == nil && utils.IsSuccessStatus(result.StatusCode) {
|
||||||
}
|
break
|
||||||
|
}
|
||||||
// 刷新 DNS
|
}
|
||||||
s.resolveWebsiteIP(*website)
|
|
||||||
|
record := models.MonitorRecord{
|
||||||
s.storage.SaveAll()
|
WebsiteID: website.ID,
|
||||||
}
|
URLID: urlInfo.ID,
|
||||||
|
URL: urlInfo.URL,
|
||||||
// GetWebsiteStatus 获取网站状态
|
StatusCode: result.StatusCode,
|
||||||
func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus {
|
Latency: result.Latency.Milliseconds(),
|
||||||
website := s.storage.GetWebsite(websiteID)
|
IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode),
|
||||||
if website == nil {
|
CheckedAt: time.Now(),
|
||||||
return nil
|
}
|
||||||
}
|
|
||||||
|
if result.Error != nil {
|
||||||
status := &models.WebsiteStatus{
|
record.Error = result.Error.Error()
|
||||||
Website: *website,
|
}
|
||||||
URLStatuses: []models.URLStatus{},
|
|
||||||
}
|
s.storage.AddRecord(record)
|
||||||
|
|
||||||
now := time.Now()
|
// 仅当网站无标题时才做完整检测来获取元数据
|
||||||
since24h := now.Add(-24 * time.Hour)
|
if record.IsUp && website.Title == "" {
|
||||||
since7d := now.Add(-7 * 24 * time.Hour)
|
fullResult := s.httpClient.CheckWebsite(urlInfo.URL)
|
||||||
since90d := now.Add(-90 * 24 * time.Hour)
|
if fullResult.Title != "" {
|
||||||
|
w := s.storage.GetWebsite(website.ID)
|
||||||
var totalUptime24h, totalUptime7d float64
|
if w != nil {
|
||||||
var urlCount int
|
w.Title = fullResult.Title
|
||||||
var allRecords90d []models.MonitorRecord
|
w.UpdatedAt = time.Now()
|
||||||
|
s.storage.UpdateWebsite(*w)
|
||||||
for _, urlInfo := range website.URLs {
|
}
|
||||||
urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d)
|
}
|
||||||
status.URLStatuses = append(status.URLStatuses, urlStatus)
|
}
|
||||||
|
|
||||||
totalUptime24h += urlStatus.Uptime24h
|
log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v",
|
||||||
totalUptime7d += urlStatus.Uptime7d
|
website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp)
|
||||||
urlCount++
|
}
|
||||||
|
|
||||||
records90d := s.storage.GetRecords(website.ID, urlInfo.ID, since90d)
|
// CheckWebsiteNow 立即检查指定网站(状态 + DNS,等待完成后保存)
|
||||||
allRecords90d = append(allRecords90d, records90d...)
|
func (s *MonitorService) CheckWebsiteNow(websiteID string) {
|
||||||
}
|
website := s.storage.GetWebsite(websiteID)
|
||||||
|
if website == nil {
|
||||||
if urlCount > 0 {
|
return
|
||||||
status.Uptime24h = totalUptime24h / float64(urlCount)
|
}
|
||||||
status.Uptime7d = totalUptime7d / float64(urlCount)
|
|
||||||
}
|
// 逐个检测该网站的所有 URL
|
||||||
|
for _, urlInfo := range website.URLs {
|
||||||
// 90 天逐日统计
|
s.checkURL(*website, urlInfo)
|
||||||
status.DailyHistory = s.aggregateByDay(allRecords90d)
|
}
|
||||||
if len(allRecords90d) > 0 {
|
|
||||||
upCount := 0
|
// 刷新 DNS
|
||||||
for _, r := range allRecords90d {
|
s.resolveWebsiteIP(*website)
|
||||||
if r.IsUp {
|
|
||||||
upCount++
|
s.storage.SaveAll()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
status.Uptime90d = float64(upCount) / float64(len(allRecords90d)) * 100
|
// GetWebsiteStatus 获取网站状态
|
||||||
}
|
func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus {
|
||||||
|
website := s.storage.GetWebsite(websiteID)
|
||||||
// 获取最后检测时间
|
if website == nil {
|
||||||
for _, urlStatus := range status.URLStatuses {
|
return nil
|
||||||
if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) {
|
}
|
||||||
status.LastChecked = urlStatus.CurrentState.CheckedAt
|
|
||||||
}
|
isIntranet := len(website.URLs) > 0 && utils.IsIntranetURL(website.URLs[0].URL)
|
||||||
}
|
status := &models.WebsiteStatus{
|
||||||
|
Website: *website,
|
||||||
return status
|
IsIntranet: isIntranet,
|
||||||
}
|
URLStatuses: []models.URLStatus{},
|
||||||
|
}
|
||||||
// getURLStatus 获取URL状态
|
|
||||||
func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus {
|
now := time.Now()
|
||||||
urlStatus := models.URLStatus{
|
since24h := now.Add(-24 * time.Hour)
|
||||||
URLInfo: urlInfo,
|
since7d := now.Add(-7 * 24 * time.Hour)
|
||||||
}
|
since90d := now.Add(-90 * 24 * time.Hour)
|
||||||
|
|
||||||
// 获取最新记录
|
var totalUptime24h, totalUptime7d float64
|
||||||
latest := s.storage.GetLatestRecord(websiteID, urlInfo.ID)
|
var urlCount int
|
||||||
if latest != nil {
|
|
||||||
urlStatus.CurrentState = *latest
|
for _, urlInfo := range website.URLs {
|
||||||
}
|
urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d)
|
||||||
|
status.URLStatuses = append(status.URLStatuses, urlStatus)
|
||||||
// 获取24小时记录
|
|
||||||
records24h := s.storage.GetRecords(websiteID, urlInfo.ID, since24h)
|
totalUptime24h += urlStatus.Uptime24h
|
||||||
urlStatus.History24h = records24h
|
totalUptime7d += urlStatus.Uptime7d
|
||||||
|
urlCount++
|
||||||
// 计算24小时可用率
|
}
|
||||||
if len(records24h) > 0 {
|
|
||||||
upCount := 0
|
if urlCount > 0 {
|
||||||
var totalLatency int64
|
status.Uptime24h = totalUptime24h / float64(urlCount)
|
||||||
for _, r := range records24h {
|
status.Uptime7d = totalUptime7d / float64(urlCount)
|
||||||
if r.IsUp {
|
}
|
||||||
upCount++
|
|
||||||
}
|
status.DailyHistory = s.storage.GetWebsiteDailyAggregates(website.ID, since90d)
|
||||||
totalLatency += r.Latency
|
if pt, ut := s.storage.GetWebsiteDayProbeTotals(website.ID, since90d); pt > 0 {
|
||||||
}
|
status.Uptime90d = float64(ut) / float64(pt) * 100
|
||||||
urlStatus.Uptime24h = float64(upCount) / float64(len(records24h)) * 100
|
}
|
||||||
urlStatus.AvgLatency = totalLatency / int64(len(records24h))
|
|
||||||
}
|
// 获取最后检测时间
|
||||||
|
for _, urlStatus := range status.URLStatuses {
|
||||||
// 获取7天记录并按小时统计
|
if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) {
|
||||||
records7d := s.storage.GetRecords(websiteID, urlInfo.ID, since7d)
|
status.LastChecked = urlStatus.CurrentState.CheckedAt
|
||||||
urlStatus.History7d = s.aggregateByHour(records7d)
|
}
|
||||||
|
}
|
||||||
// 计算7天可用率
|
|
||||||
if len(records7d) > 0 {
|
return status
|
||||||
upCount := 0
|
}
|
||||||
for _, r := range records7d {
|
|
||||||
if r.IsUp {
|
// getURLStatus 获取URL状态:每个 URL 仅 1 次读小时表 + 1 次读 latest(在内存拆 24h/7d)
|
||||||
upCount++
|
func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus {
|
||||||
}
|
var latest *models.MonitorRecord
|
||||||
}
|
if rec := s.storage.GetLatestRecord(websiteID, urlInfo.ID); rec != nil {
|
||||||
urlStatus.Uptime7d = float64(upCount) / float64(len(records7d)) * 100
|
latest = rec
|
||||||
}
|
}
|
||||||
|
hRows := s.storage.GetProbeHourRowsForURL(websiteID, urlInfo.ID, since7d)
|
||||||
return urlStatus
|
return s.buildURLStatusFromHours(websiteID, urlInfo, since24h, since7d, latest, hRows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// aggregateByHour 按小时聚合记录
|
func hourlyStatFromProbeRow(h storage.MonitorProbeHour) models.HourlyStats {
|
||||||
func (s *MonitorService) aggregateByHour(records []models.MonitorRecord) []models.HourlyStats {
|
st := models.HourlyStats{
|
||||||
hourlyMap := make(map[string]*models.HourlyStats)
|
Hour: h.HourAt,
|
||||||
|
TotalCount: h.ProbeCount,
|
||||||
for _, r := range records {
|
UpCount: h.UpCount,
|
||||||
hourKey := r.CheckedAt.Truncate(time.Hour).Format(time.RFC3339)
|
}
|
||||||
|
if h.ProbeCount > 0 {
|
||||||
if _, exists := hourlyMap[hourKey]; !exists {
|
st.AvgLatency = h.LatencySum / int64(h.ProbeCount)
|
||||||
hourlyMap[hourKey] = &models.HourlyStats{
|
st.Uptime = float64(h.UpCount) / float64(h.ProbeCount) * 100
|
||||||
Hour: r.CheckedAt.Truncate(time.Hour),
|
}
|
||||||
}
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
stats := hourlyMap[hourKey]
|
func hourWindowsFromRows(rows []storage.MonitorProbeHour, since24h, since7d time.Time) (
|
||||||
stats.TotalCount++
|
stats24h []models.HourlyStats,
|
||||||
if r.IsUp {
|
stats7d []models.HourlyStats,
|
||||||
stats.UpCount++
|
t24, u24, l24, t7, u7 int64,
|
||||||
}
|
) {
|
||||||
stats.AvgLatency += r.Latency
|
for _, h := range rows {
|
||||||
}
|
if !h.HourAt.Before(since7d) {
|
||||||
|
t7 += int64(h.ProbeCount)
|
||||||
var result []models.HourlyStats
|
u7 += int64(h.UpCount)
|
||||||
for _, stats := range hourlyMap {
|
stats7d = append(stats7d, hourlyStatFromProbeRow(h))
|
||||||
if stats.TotalCount > 0 {
|
}
|
||||||
stats.AvgLatency /= int64(stats.TotalCount)
|
if !h.HourAt.Before(since24h) {
|
||||||
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100
|
t24 += int64(h.ProbeCount)
|
||||||
}
|
u24 += int64(h.UpCount)
|
||||||
result = append(result, *stats)
|
l24 += h.LatencySum
|
||||||
}
|
stats24h = append(stats24h, hourlyStatFromProbeRow(h))
|
||||||
|
}
|
||||||
return result
|
}
|
||||||
}
|
return stats24h, stats7d, t24, u24, l24, t7, u7
|
||||||
|
}
|
||||||
// aggregateByDay 按天聚合记录
|
|
||||||
func (s *MonitorService) aggregateByDay(records []models.MonitorRecord) []models.DailyStats {
|
func (s *MonitorService) buildURLStatusFromHours(
|
||||||
dayMap := make(map[string]*models.DailyStats)
|
websiteID string,
|
||||||
|
urlInfo models.URLInfo,
|
||||||
for _, r := range records {
|
since24h, since7d time.Time,
|
||||||
dayTime := time.Date(r.CheckedAt.Year(), r.CheckedAt.Month(), r.CheckedAt.Day(), 0, 0, 0, 0, r.CheckedAt.Location())
|
latest *models.MonitorRecord,
|
||||||
dayKey := dayTime.Format("2006-01-02")
|
hRows []storage.MonitorProbeHour,
|
||||||
|
) models.URLStatus {
|
||||||
if _, exists := dayMap[dayKey]; !exists {
|
urlStatus := models.URLStatus{URLInfo: urlInfo}
|
||||||
dayMap[dayKey] = &models.DailyStats{
|
if latest != nil {
|
||||||
Date: dayTime,
|
urlStatus.CurrentState = *latest
|
||||||
}
|
}
|
||||||
}
|
s24, s7, t24, u24, l24, t7, u7 := hourWindowsFromRows(hRows, since24h, since7d)
|
||||||
|
urlStatus.History24h = hourlyStatsToMonitorRecords(websiteID, urlInfo.ID, s24)
|
||||||
stats := dayMap[dayKey]
|
if t24 > 0 {
|
||||||
stats.TotalCount++
|
urlStatus.Uptime24h = float64(u24) / float64(t24) * 100
|
||||||
if r.IsUp {
|
urlStatus.AvgLatency = l24 / t24
|
||||||
stats.UpCount++
|
}
|
||||||
}
|
urlStatus.History7d = s7
|
||||||
stats.AvgLatency += r.Latency
|
if t7 > 0 {
|
||||||
}
|
urlStatus.Uptime7d = float64(u7) / float64(t7) * 100
|
||||||
|
}
|
||||||
result := make([]models.DailyStats, 0, len(dayMap))
|
return urlStatus
|
||||||
for _, stats := range dayMap {
|
}
|
||||||
if stats.TotalCount > 0 {
|
|
||||||
stats.AvgLatency /= int64(stats.TotalCount)
|
func hourlyStatsToMonitorRecords(websiteID, urlID string, stats []models.HourlyStats) []models.MonitorRecord {
|
||||||
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100
|
out := make([]models.MonitorRecord, 0, len(stats))
|
||||||
}
|
for _, h := range stats {
|
||||||
result = append(result, *stats)
|
isUp := h.TotalCount > 0 && h.UpCount*2 >= h.TotalCount
|
||||||
}
|
out = append(out, models.MonitorRecord{
|
||||||
|
WebsiteID: websiteID,
|
||||||
sort.Slice(result, func(i, j int) bool {
|
URLID: urlID,
|
||||||
return result[i].Date.Before(result[j].Date)
|
Latency: h.AvgLatency,
|
||||||
})
|
IsUp: isUp,
|
||||||
|
CheckedAt: h.Hour,
|
||||||
return result
|
})
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
// GetAllWebsiteStatuses 获取所有网站状态
|
}
|
||||||
func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus {
|
|
||||||
websites := s.storage.GetWebsites()
|
// GetAllWebsiteStatuses 获取所有网站状态(批量查 probe 表,避免每个站点/URL 多次 SQL)
|
||||||
var statuses []models.WebsiteStatus
|
func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus {
|
||||||
|
websites := s.storage.GetWebsites()
|
||||||
for _, website := range websites {
|
if len(websites) == 0 {
|
||||||
status := s.GetWebsiteStatus(website.ID)
|
return nil
|
||||||
if status != nil {
|
}
|
||||||
statuses = append(statuses, *status)
|
|
||||||
}
|
now := time.Now()
|
||||||
}
|
since24h := now.Add(-24 * time.Hour)
|
||||||
|
since7d := now.Add(-7 * 24 * time.Hour)
|
||||||
return statuses
|
since90d := now.Add(-90 * 24 * time.Hour)
|
||||||
}
|
since90dDay := time.Date(since90d.Year(), since90d.Month(), since90d.Day(), 0, 0, 0, 0, since90d.Location())
|
||||||
|
|
||||||
|
latestMap := s.storage.GetProbeLatestKeyMap()
|
||||||
|
hourMap := s.storage.GroupProbeHoursSince(since7d)
|
||||||
|
dailyBySite, uptime90dBySite := s.storage.LoadAllWebsiteDayRollups(since90dDay)
|
||||||
|
|
||||||
|
statuses := make([]models.WebsiteStatus, 0, len(websites))
|
||||||
|
for _, w := range websites {
|
||||||
|
daily := dailyBySite[w.ID]
|
||||||
|
u90 := uptime90dBySite[w.ID]
|
||||||
|
st := s.buildWebsiteStatusBulk(w, since24h, since7d, latestMap, hourMap, daily, u90)
|
||||||
|
if st != nil {
|
||||||
|
statuses = append(statuses, *st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MonitorService) buildWebsiteStatusBulk(
|
||||||
|
w models.Website,
|
||||||
|
since24h, since7d time.Time,
|
||||||
|
latestMap map[string]models.MonitorRecord,
|
||||||
|
hourMap map[string]map[string][]storage.MonitorProbeHour,
|
||||||
|
daily []models.DailyStats,
|
||||||
|
uptime90d float64,
|
||||||
|
) *models.WebsiteStatus {
|
||||||
|
isIntranet := len(w.URLs) > 0 && utils.IsIntranetURL(w.URLs[0].URL)
|
||||||
|
status := &models.WebsiteStatus{
|
||||||
|
Website: w,
|
||||||
|
IsIntranet: isIntranet,
|
||||||
|
URLStatuses: []models.URLStatus{},
|
||||||
|
}
|
||||||
|
|
||||||
|
siteHours := hourMap[w.ID]
|
||||||
|
if siteHours == nil {
|
||||||
|
siteHours = map[string][]storage.MonitorProbeHour{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalUptime24h, totalUptime7d float64
|
||||||
|
urlCount := 0
|
||||||
|
for _, urlInfo := range w.URLs {
|
||||||
|
key := w.ID + "\x00" + urlInfo.ID
|
||||||
|
var latest *models.MonitorRecord
|
||||||
|
if rec, ok := latestMap[key]; ok {
|
||||||
|
r := rec
|
||||||
|
latest = &r
|
||||||
|
}
|
||||||
|
hRows := siteHours[urlInfo.ID]
|
||||||
|
urlSt := s.buildURLStatusFromHours(w.ID, urlInfo, since24h, since7d, latest, hRows)
|
||||||
|
status.URLStatuses = append(status.URLStatuses, urlSt)
|
||||||
|
totalUptime24h += urlSt.Uptime24h
|
||||||
|
totalUptime7d += urlSt.Uptime7d
|
||||||
|
urlCount++
|
||||||
|
}
|
||||||
|
if urlCount > 0 {
|
||||||
|
status.Uptime24h = totalUptime24h / float64(urlCount)
|
||||||
|
status.Uptime7d = totalUptime7d / float64(urlCount)
|
||||||
|
}
|
||||||
|
if daily != nil {
|
||||||
|
status.DailyHistory = daily
|
||||||
|
} else {
|
||||||
|
status.DailyHistory = []models.DailyStats{}
|
||||||
|
}
|
||||||
|
status.Uptime90d = uptime90d
|
||||||
|
for _, urlSt := range status.URLStatuses {
|
||||||
|
if urlSt.CurrentState.CheckedAt.After(status.LastChecked) {
|
||||||
|
status.LastChecked = urlSt.CurrentState.CheckedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,142 +1,177 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"mengyaping-backend/models"
|
"time"
|
||||||
"mengyaping-backend/storage"
|
|
||||||
"mengyaping-backend/utils"
|
"mengyaping-backend/models"
|
||||||
)
|
"mengyaping-backend/storage"
|
||||||
|
"mengyaping-backend/utils"
|
||||||
// WebsiteService 网站服务
|
)
|
||||||
type WebsiteService struct {
|
|
||||||
storage *storage.Storage
|
// WebsiteService 网站服务
|
||||||
}
|
type WebsiteService struct {
|
||||||
|
storage *storage.Storage
|
||||||
// NewWebsiteService 创建网站服务
|
}
|
||||||
func NewWebsiteService() *WebsiteService {
|
|
||||||
return &WebsiteService{
|
// NewWebsiteService 创建网站服务
|
||||||
storage: storage.GetStorage(),
|
func NewWebsiteService() *WebsiteService {
|
||||||
}
|
return &WebsiteService{
|
||||||
}
|
storage: storage.GetStorage(),
|
||||||
|
}
|
||||||
// CreateWebsite 创建网站
|
}
|
||||||
func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) {
|
|
||||||
groups := req.Groups
|
// CreateWebsite 创建网站
|
||||||
if len(groups) == 0 && req.Group != "" {
|
func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) {
|
||||||
groups = []string{req.Group}
|
groups := req.Groups
|
||||||
}
|
if len(groups) == 0 && req.Group != "" {
|
||||||
|
groups = []string{req.Group}
|
||||||
website := models.Website{
|
}
|
||||||
ID: utils.GenerateID(),
|
|
||||||
Name: req.Name,
|
website := models.Website{
|
||||||
Groups: groups,
|
ID: utils.GenerateID(),
|
||||||
URLs: make([]models.URLInfo, 0),
|
Name: req.Name,
|
||||||
CreatedAt: time.Now(),
|
Groups: groups,
|
||||||
UpdatedAt: time.Now(),
|
URLs: make([]models.URLInfo, 0),
|
||||||
}
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
for _, url := range req.URLs {
|
}
|
||||||
urlInfo := models.URLInfo{
|
|
||||||
ID: utils.GenerateShortID(),
|
for _, url := range req.URLs {
|
||||||
URL: url,
|
urlInfo := models.URLInfo{
|
||||||
}
|
ID: utils.GenerateShortID(),
|
||||||
website.URLs = append(website.URLs, urlInfo)
|
URL: url,
|
||||||
}
|
}
|
||||||
|
website.URLs = append(website.URLs, urlInfo)
|
||||||
// 创建前先解析域名 IP
|
}
|
||||||
if len(req.URLs) > 0 {
|
|
||||||
if ips, err := utils.ResolveDomainIPs(req.URLs[0]); err == nil {
|
if len(req.URLs) > 0 {
|
||||||
website.IPAddresses = ips
|
if utils.IsIntranetURL(req.URLs[0]) {
|
||||||
}
|
website.Favicon = models.DefaultIntranetFavicon
|
||||||
}
|
website.IPAddresses = nil
|
||||||
|
} else {
|
||||||
if err := s.storage.AddWebsite(website); err != nil {
|
if ips, err := utils.ResolveDomainIPs(req.URLs[0]); err == nil {
|
||||||
return nil, err
|
website.IPAddresses = ips
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// 立即检测该网站
|
}
|
||||||
go GetMonitorService().CheckWebsiteNow(website.ID)
|
|
||||||
|
if err := s.storage.AddWebsite(website); err != nil {
|
||||||
return &website, nil
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWebsite 获取网站
|
// 立即检测该网站
|
||||||
func (s *WebsiteService) GetWebsite(id string) *models.Website {
|
go GetMonitorService().CheckWebsiteNow(website.ID)
|
||||||
return s.storage.GetWebsite(id)
|
|
||||||
}
|
return &website, nil
|
||||||
|
}
|
||||||
// GetAllWebsites 获取所有网站
|
|
||||||
func (s *WebsiteService) GetAllWebsites() []models.Website {
|
// GetWebsite 获取网站
|
||||||
return s.storage.GetWebsites()
|
func (s *WebsiteService) GetWebsite(id string) *models.Website {
|
||||||
}
|
return s.storage.GetWebsite(id)
|
||||||
|
}
|
||||||
// UpdateWebsite 更新网站
|
|
||||||
func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteRequest) (*models.Website, error) {
|
// GetAllWebsites 获取所有网站
|
||||||
website := s.storage.GetWebsite(id)
|
func (s *WebsiteService) GetAllWebsites() []models.Website {
|
||||||
if website == nil {
|
return s.storage.GetWebsites()
|
||||||
return nil, nil
|
}
|
||||||
}
|
|
||||||
|
// UpdateWebsite 更新网站
|
||||||
if req.Name != "" {
|
func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteRequest) (*models.Website, error) {
|
||||||
website.Name = req.Name
|
website := s.storage.GetWebsite(id)
|
||||||
}
|
if website == nil {
|
||||||
if len(req.Groups) > 0 {
|
return nil, nil
|
||||||
website.Groups = req.Groups
|
}
|
||||||
} else if req.Group != "" {
|
|
||||||
website.Groups = []string{req.Group}
|
if req.Name != "" {
|
||||||
}
|
website.Name = req.Name
|
||||||
if len(req.URLs) > 0 {
|
}
|
||||||
// 保留已有URL的ID,添加新URL
|
if len(req.Groups) > 0 {
|
||||||
existingURLs := make(map[string]models.URLInfo)
|
website.Groups = req.Groups
|
||||||
for _, u := range website.URLs {
|
} else if req.Group != "" {
|
||||||
existingURLs[u.URL] = u
|
website.Groups = []string{req.Group}
|
||||||
}
|
}
|
||||||
|
if len(req.URLs) > 0 {
|
||||||
newURLs := make([]models.URLInfo, 0)
|
// 保留已有URL的ID,添加新URL
|
||||||
for _, url := range req.URLs {
|
existingURLs := make(map[string]models.URLInfo)
|
||||||
if existing, ok := existingURLs[url]; ok {
|
for _, u := range website.URLs {
|
||||||
newURLs = append(newURLs, existing)
|
existingURLs[u.URL] = u
|
||||||
} else {
|
}
|
||||||
newURLs = append(newURLs, models.URLInfo{
|
|
||||||
ID: utils.GenerateShortID(),
|
newURLs := make([]models.URLInfo, 0)
|
||||||
URL: url,
|
for _, url := range req.URLs {
|
||||||
})
|
if existing, ok := existingURLs[url]; ok {
|
||||||
}
|
newURLs = append(newURLs, existing)
|
||||||
}
|
} else {
|
||||||
website.URLs = newURLs
|
newURLs = append(newURLs, models.URLInfo{
|
||||||
website.IPAddresses = nil // URL 变更后清空 IP,等下次检测重新解析
|
ID: utils.GenerateShortID(),
|
||||||
}
|
URL: url,
|
||||||
|
})
|
||||||
website.UpdatedAt = time.Now()
|
}
|
||||||
|
}
|
||||||
if err := s.storage.UpdateWebsite(*website); err != nil {
|
website.URLs = newURLs
|
||||||
return nil, err
|
website.IPAddresses = nil // URL 变更后清空 IP,等下次检测重新解析
|
||||||
}
|
|
||||||
|
if len(req.URLs) > 0 && utils.IsIntranetURL(req.URLs[0]) {
|
||||||
return website, nil
|
website.Favicon = models.DefaultIntranetFavicon
|
||||||
}
|
website.IPAddresses = nil
|
||||||
|
}
|
||||||
// DeleteWebsite 删除网站
|
}
|
||||||
func (s *WebsiteService) DeleteWebsite(id string) error {
|
|
||||||
return s.storage.DeleteWebsite(id)
|
website.UpdatedAt = time.Now()
|
||||||
}
|
|
||||||
|
if err := s.storage.UpdateWebsite(*website); err != nil {
|
||||||
// GetGroups 获取所有分组
|
return nil, err
|
||||||
func (s *WebsiteService) GetGroups() []models.Group {
|
}
|
||||||
return s.storage.GetGroups()
|
|
||||||
}
|
return website, nil
|
||||||
|
}
|
||||||
// AddGroup 添加分组
|
|
||||||
func (s *WebsiteService) AddGroup(name string) (*models.Group, error) {
|
// DeleteWebsite 删除网站
|
||||||
group := models.Group{
|
func (s *WebsiteService) DeleteWebsite(id string) error {
|
||||||
ID: utils.GenerateShortID(),
|
return s.storage.DeleteWebsite(id)
|
||||||
Name: name,
|
}
|
||||||
}
|
|
||||||
|
// GetGroups 获取所有分组
|
||||||
if err := s.storage.AddGroup(group); err != nil {
|
func (s *WebsiteService) GetGroups() []models.Group {
|
||||||
return nil, err
|
return s.storage.GetGroups()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &group, nil
|
// AddGroup 添加分组
|
||||||
}
|
func (s *WebsiteService) AddGroup(name string) (*models.Group, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("分类名称不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
group := models.Group{
|
||||||
|
ID: utils.GenerateShortID(),
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.storage.AddGroup(group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroup 获取单个分组
|
||||||
|
func (s *WebsiteService) GetGroup(id string) *models.Group {
|
||||||
|
return s.storage.GetGroupByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroup 更新分类名称
|
||||||
|
func (s *WebsiteService) UpdateGroup(id string, name string) (*models.Group, error) {
|
||||||
|
if err := s.storage.UpdateGroup(id, name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
g := s.storage.GetGroupByID(id)
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroup 删除分类
|
||||||
|
func (s *WebsiteService) DeleteGroup(id string) error {
|
||||||
|
return s.storage.DeleteGroup(id)
|
||||||
|
}
|
||||||
|
|||||||
161
mengyaping-backend/storage/app_config.go
Normal file
161
mengyaping-backend/storage/app_config.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mengyaping-backend/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
kvKeyServer = "cfg_server"
|
||||||
|
kvKeyMonitor = "cfg_monitor"
|
||||||
|
kvKeyDataPath = "cfg_data_path"
|
||||||
|
kvKeyDatabase = "cfg_database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type monitorKVPayload struct {
|
||||||
|
IntervalMinutes int `json:"interval_minutes"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
|
RetryCount int `json:"retry_count"`
|
||||||
|
HistoryDays int `json:"history_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) getKVRaw(key string) string {
|
||||||
|
var row MonitorKV
|
||||||
|
s.db.Where("cfg_key = ?", key).Limit(1).Find(&row)
|
||||||
|
if row.CfgKey == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return row.CfgValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) setKVRaw(key, val string) error {
|
||||||
|
var row MonitorKV
|
||||||
|
s.db.Where("cfg_key = ?", key).Limit(1).Find(&row)
|
||||||
|
if row.CfgKey == "" {
|
||||||
|
return s.db.Create(&MonitorKV{CfgKey: key, CfgValue: val}).Error
|
||||||
|
}
|
||||||
|
row.CfgValue = val
|
||||||
|
return s.db.Save(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAndSyncAppConfig 从 monitor_kv 覆盖内存配置,再写回全量(补全缺省键、统一格式)
|
||||||
|
func (s *Storage) loadAndSyncAppConfig() {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
|
if v := s.getKVRaw(kvKeyServer); v != "" {
|
||||||
|
var sc config.ServerConfig
|
||||||
|
if err := json.Unmarshal([]byte(v), &sc); err != nil {
|
||||||
|
log.Printf("解析 %s: %v", kvKeyServer, err)
|
||||||
|
} else {
|
||||||
|
if sc.Port != "" {
|
||||||
|
cfg.Server.Port = sc.Port
|
||||||
|
}
|
||||||
|
if sc.Host != "" {
|
||||||
|
cfg.Server.Host = sc.Host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := s.getKVRaw(kvKeyMonitor); v != "" {
|
||||||
|
var m monitorKVPayload
|
||||||
|
if err := json.Unmarshal([]byte(v), &m); err != nil {
|
||||||
|
log.Printf("解析 %s: %v", kvKeyMonitor, err)
|
||||||
|
} else {
|
||||||
|
if m.IntervalMinutes > 0 {
|
||||||
|
snapped := config.SnapMonitorIntervalMinutes(m.IntervalMinutes)
|
||||||
|
cfg.Monitor.Interval = time.Duration(snapped) * time.Minute
|
||||||
|
}
|
||||||
|
if m.TimeoutSeconds > 0 {
|
||||||
|
cfg.Monitor.Timeout = time.Duration(m.TimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
if m.RetryCount > 0 {
|
||||||
|
cfg.Monitor.RetryCount = m.RetryCount
|
||||||
|
}
|
||||||
|
if m.HistoryDays > 0 {
|
||||||
|
cfg.Monitor.HistoryDays = m.HistoryDays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := s.getKVRaw(kvKeyDataPath); v != "" {
|
||||||
|
cfg.DataPath = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := s.getKVRaw(kvKeyDatabase); v != "" {
|
||||||
|
var dc config.DatabaseConfig
|
||||||
|
if err := json.Unmarshal([]byte(v), &dc); err != nil {
|
||||||
|
log.Printf("解析 %s: %v", kvKeyDatabase, err)
|
||||||
|
} else {
|
||||||
|
if dc.Host != "" {
|
||||||
|
cfg.Database.Host = dc.Host
|
||||||
|
}
|
||||||
|
if dc.Port != "" {
|
||||||
|
cfg.Database.Port = dc.Port
|
||||||
|
}
|
||||||
|
if dc.User != "" {
|
||||||
|
cfg.Database.User = dc.User
|
||||||
|
}
|
||||||
|
if dc.Password != "" {
|
||||||
|
cfg.Database.Password = dc.Password
|
||||||
|
}
|
||||||
|
if dc.Database != "" {
|
||||||
|
cfg.Database.Database = dc.Database
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.PersistAppConfig(); err != nil {
|
||||||
|
log.Printf("同步应用配置到 monitor_kv: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersistAppConfig 将当前内存中的 server/monitor/data_path/database 写入 monitor_kv
|
||||||
|
func (s *Storage) PersistAppConfig() error {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
|
b, err := json.Marshal(cfg.Server)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.setKVRaw(kvKeyServer, string(b)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m := monitorKVPayload{
|
||||||
|
IntervalMinutes: config.SnapMonitorIntervalMinutes(int(cfg.Monitor.Interval / time.Minute)),
|
||||||
|
TimeoutSeconds: int(cfg.Monitor.Timeout.Seconds()),
|
||||||
|
RetryCount: cfg.Monitor.RetryCount,
|
||||||
|
HistoryDays: cfg.Monitor.HistoryDays,
|
||||||
|
}
|
||||||
|
b, err = json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.setKVRaw(kvKeyMonitor, string(b)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.setKVRaw(kvKeyDataPath, cfg.DataPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err = json.Marshal(cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.setKVRaw(kvKeyDatabase, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMonitorIntervalMinutes 更新检测周期并写入 monitor_kv
|
||||||
|
func (s *Storage) SetMonitorIntervalMinutes(minutes int) error {
|
||||||
|
if !config.IsAllowedMonitorInterval(minutes) {
|
||||||
|
return fmt.Errorf("interval_minutes 必须是预设值之一: %v", config.AllowedMonitorIntervalMinutes)
|
||||||
|
}
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
cfg.Monitor.Interval = time.Duration(minutes) * time.Minute
|
||||||
|
return s.PersistAppConfig()
|
||||||
|
}
|
||||||
14
mengyaping-backend/storage/db_kv.go
Normal file
14
mengyaping-backend/storage/db_kv.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// MonitorKV 键值配置(管理员令牌等),列名避开 MySQL 保留字 key/value
|
||||||
|
type MonitorKV struct {
|
||||||
|
CfgKey string `gorm:"primaryKey;size:128;column:cfg_key"`
|
||||||
|
CfgValue string `gorm:"type:text;column:cfg_value"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MonitorKV) TableName() string { return "monitor_kv" }
|
||||||
|
|
||||||
|
const kvKeyAdminToken = "admin_token"
|
||||||
93
mengyaping-backend/storage/db_models.go
Normal file
93
mengyaping-backend/storage/db_models.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MonitorGroup 网站分类(避免使用 MySQL 保留字 group)
|
||||||
|
type MonitorGroup struct {
|
||||||
|
ID string `gorm:"primaryKey;size:64"`
|
||||||
|
Name string `gorm:"size:255;not null"`
|
||||||
|
SortOrder int `gorm:"default:0;index"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MonitorGroup) TableName() string { return "monitor_groups" }
|
||||||
|
|
||||||
|
// MonitorWebsite 监控站点主表
|
||||||
|
type MonitorWebsite struct {
|
||||||
|
ID string `gorm:"primaryKey;size:128"`
|
||||||
|
Name string `gorm:"size:255;not null;index"`
|
||||||
|
LegacyGroup string `gorm:"size:64;column:legacy_group"` // 旧版单分组,仅兼容
|
||||||
|
Favicon string `gorm:"type:text"`
|
||||||
|
Title string `gorm:"size:512"`
|
||||||
|
IPAddresses datatypes.JSON `gorm:"type:json"` // []string
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||||
|
|
||||||
|
URLs []MonitorWebsiteURL `gorm:"foreignKey:WebsiteID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
|
GroupLinks []MonitorWebsiteGroup `gorm:"foreignKey:WebsiteID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MonitorWebsite) TableName() string { return "monitor_websites" }
|
||||||
|
|
||||||
|
// MonitorWebsiteURL 站点监控 URL(一对多)
|
||||||
|
type MonitorWebsiteURL struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||||
|
WebsiteID string `gorm:"size:128;not null;index:idx_url_site,priority:1"`
|
||||||
|
URLID string `gorm:"size:64;not null;index:idx_url_site,priority:2"`
|
||||||
|
URL string `gorm:"type:text;not null"`
|
||||||
|
Remark string `gorm:"size:512"`
|
||||||
|
SortOrder int `gorm:"default:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MonitorWebsiteURL) TableName() string { return "monitor_website_urls" }
|
||||||
|
|
||||||
|
// MonitorWebsiteGroup 站点与分类多对多
|
||||||
|
type MonitorWebsiteGroup struct {
|
||||||
|
WebsiteID string `gorm:"primaryKey;size:128"`
|
||||||
|
GroupID string `gorm:"primaryKey;size:64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MonitorWebsiteGroup) TableName() string { return "monitor_website_groups" }
|
||||||
|
|
||||||
|
// MonitorProbeLatest 每个 URL 最新一次探测结果(不逐条存历史)
|
||||||
|
type MonitorProbeLatest struct {
|
||||||
|
WebsiteID string `gorm:"primaryKey;size:128"`
|
||||||
|
URLID string `gorm:"primaryKey;size:64"`
|
||||||
|
URL string `gorm:"type:text"`
|
||||||
|
StatusCode int `gorm:"column:status_code"`
|
||||||
|
LatencyMs int64 `gorm:"column:latency_ms"`
|
||||||
|
IsUp bool `gorm:"column:is_up"`
|
||||||
|
ErrorText string `gorm:"type:text;column:error_text"`
|
||||||
|
CheckedAt time.Time `gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MonitorProbeLatest) TableName() string { return "monitor_probe_latest" }
|
||||||
|
|
||||||
|
// MonitorProbeHour 按小时汇总(用于 24h / 7d 统计与图表),行数 ≈ URL 数 × 小时数
|
||||||
|
type MonitorProbeHour struct {
|
||||||
|
WebsiteID string `gorm:"primaryKey;size:128"`
|
||||||
|
URLID string `gorm:"primaryKey;size:64"`
|
||||||
|
HourAt time.Time `gorm:"primaryKey;type:datetime"` // 整点
|
||||||
|
ProbeCount int `gorm:"default:0"`
|
||||||
|
UpCount int `gorm:"default:0"`
|
||||||
|
LatencySum int64 `gorm:"default:0"` // 总延迟 ms,均值为 sum/count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MonitorProbeHour) TableName() string { return "monitor_probe_hour" }
|
||||||
|
|
||||||
|
// MonitorProbeDay 按自然日汇总(用于 90 天柱状图),行数 ≈ URL 数 × 天数
|
||||||
|
type MonitorProbeDay struct {
|
||||||
|
WebsiteID string `gorm:"primaryKey;size:128"`
|
||||||
|
URLID string `gorm:"primaryKey;size:64"`
|
||||||
|
StatDate time.Time `gorm:"primaryKey;type:date"`
|
||||||
|
ProbeCount int `gorm:"default:0"`
|
||||||
|
UpCount int `gorm:"default:0"`
|
||||||
|
LatencySum int64 `gorm:"default:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MonitorProbeDay) TableName() string { return "monitor_probe_day" }
|
||||||
62
mengyaping-backend/storage/kv.go
Normal file
62
mengyaping-backend/storage/kv.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errEmptyAdminToken = errors.New("admin_token 不能为空")
|
||||||
|
|
||||||
|
// EffectiveAdminToken 管理员令牌:环境变量 ADMIN_TOKEN 优先,否则读 MySQL monitor_kv
|
||||||
|
func (s *Storage) EffectiveAdminToken() string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("ADMIN_TOKEN")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s.adminTokenFromDB())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) adminTokenFromDB() string {
|
||||||
|
var row MonitorKV
|
||||||
|
if err := s.db.Where("cfg_key = ?", kvKeyAdminToken).Limit(1).Find(&row).Error; err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if row.CfgKey == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return row.CfgValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAdminToken 更新数据库中的管理员令牌(需已能通过 AdminAuth)
|
||||||
|
func (s *Storage) SetAdminToken(token string) error {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return errEmptyAdminToken
|
||||||
|
}
|
||||||
|
var row MonitorKV
|
||||||
|
s.db.Where("cfg_key = ?", kvKeyAdminToken).Limit(1).Find(&row)
|
||||||
|
if row.CfgKey == "" {
|
||||||
|
return s.db.Create(&MonitorKV{CfgKey: kvKeyAdminToken, CfgValue: token}).Error
|
||||||
|
}
|
||||||
|
row.CfgValue = token
|
||||||
|
return s.db.Save(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) seedAdminTokenKV() {
|
||||||
|
var n int64
|
||||||
|
s.db.Model(&MonitorKV{}).Where("cfg_key = ?", kvKeyAdminToken).Count(&n)
|
||||||
|
if n > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val := strings.TrimSpace(os.Getenv("ADMIN_TOKEN"))
|
||||||
|
if val == "" {
|
||||||
|
log.Println("提示: 数据库 monitor_kv 中尚无 admin_token。可设置环境变量 ADMIN_TOKEN 后重启以自动写入,或对 monitor_kv 执行 INSERT。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&MonitorKV{CfgKey: kvKeyAdminToken, CfgValue: val}).Error; err != nil {
|
||||||
|
log.Printf("写入初始 admin_token 失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("已从环境变量 ADMIN_TOKEN 写入数据库 monitor_kv(后续可去掉该环境变量,改用库中令牌)。")
|
||||||
|
}
|
||||||
295
mengyaping-backend/storage/probe.go
Normal file
295
mengyaping-backend/storage/probe.go
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"mengyaping-backend/config"
|
||||||
|
"mengyaping-backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func latestToRecord(p *MonitorProbeLatest) models.MonitorRecord {
|
||||||
|
return models.MonitorRecord{
|
||||||
|
WebsiteID: p.WebsiteID,
|
||||||
|
URLID: p.URLID,
|
||||||
|
URL: p.URL,
|
||||||
|
StatusCode: p.StatusCode,
|
||||||
|
Latency: p.LatencyMs,
|
||||||
|
IsUp: p.IsUp,
|
||||||
|
Error: p.ErrorText,
|
||||||
|
CheckedAt: p.CheckedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRecord 写入最新探测 + 按小时/按天汇总(不插逐条历史)
|
||||||
|
func (s *Storage) AddRecord(record models.MonitorRecord) error {
|
||||||
|
hourAt := record.CheckedAt.Truncate(time.Hour)
|
||||||
|
statDate := time.Date(record.CheckedAt.Year(), record.CheckedAt.Month(), record.CheckedAt.Day(),
|
||||||
|
0, 0, 0, 0, record.CheckedAt.Location())
|
||||||
|
upDelta := 0
|
||||||
|
if record.IsUp {
|
||||||
|
upDelta = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
lp := MonitorProbeLatest{
|
||||||
|
WebsiteID: record.WebsiteID,
|
||||||
|
URLID: record.URLID,
|
||||||
|
URL: record.URL,
|
||||||
|
StatusCode: record.StatusCode,
|
||||||
|
LatencyMs: record.Latency,
|
||||||
|
IsUp: record.IsUp,
|
||||||
|
ErrorText: record.Error,
|
||||||
|
CheckedAt: record.CheckedAt,
|
||||||
|
}
|
||||||
|
if err := tx.Save(&lp).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO monitor_probe_hour (website_id, url_id, hour_at, probe_count, up_count, latency_sum)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
probe_count = probe_count + 1,
|
||||||
|
up_count = up_count + VALUES(up_count),
|
||||||
|
latency_sum = latency_sum + VALUES(latency_sum)
|
||||||
|
`, record.WebsiteID, record.URLID, hourAt, upDelta, record.Latency).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec(`
|
||||||
|
INSERT INTO monitor_probe_day (website_id, url_id, stat_date, probe_count, up_count, latency_sum)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
probe_count = probe_count + 1,
|
||||||
|
up_count = up_count + VALUES(up_count),
|
||||||
|
latency_sum = latency_sum + VALUES(latency_sum)
|
||||||
|
`, record.WebsiteID, record.URLID, statDate.Format("2006-01-02"), upDelta, record.Latency).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestRecord 最新一条(无记录时返回 nil;用 Find 而非 First,避免 GORM 将「未找到」打成 record not found 日志)
|
||||||
|
func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord {
|
||||||
|
var p MonitorProbeLatest
|
||||||
|
if err := s.db.Where("website_id = ? AND url_id = ?", websiteID, urlID).Limit(1).Find(&p).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.WebsiteID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := latestToRecord(&p)
|
||||||
|
return &m
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecords 兼容旧接口:按小时聚合展开为「代表点」(每探测窗口 1 条),仅用于仍依赖 slice 的逻辑;新逻辑请用汇总 API
|
||||||
|
func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord {
|
||||||
|
rows := s.getProbeHourRows(websiteID, urlID, since)
|
||||||
|
var out []models.MonitorRecord
|
||||||
|
for _, h := range rows {
|
||||||
|
if h.ProbeCount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
avgLat := h.LatencySum / int64(h.ProbeCount)
|
||||||
|
isUp := h.UpCount*2 >= h.ProbeCount
|
||||||
|
out = append(out, models.MonitorRecord{
|
||||||
|
WebsiteID: websiteID,
|
||||||
|
URLID: urlID,
|
||||||
|
StatusCode: 0,
|
||||||
|
Latency: avgLat,
|
||||||
|
IsUp: isUp,
|
||||||
|
CheckedAt: h.HourAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) getProbeHourRows(websiteID, urlID string, since time.Time) []MonitorProbeHour {
|
||||||
|
var rows []MonitorProbeHour
|
||||||
|
s.db.Where("website_id = ? AND url_id = ? AND hour_at >= ?", websiteID, urlID, since).
|
||||||
|
Order("hour_at ASC").Find(&rows)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProbeHourRowsForURL 某 URL 自 since 起的小时汇总行(升序)
|
||||||
|
func (s *Storage) GetProbeHourRowsForURL(websiteID, urlID string, since time.Time) []MonitorProbeHour {
|
||||||
|
return s.getProbeHourRows(websiteID, urlID, since)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProbeLatestKeyMap 全表 latest(每 URL 一行),key = website_id + "\x00" + url_id
|
||||||
|
func (s *Storage) GetProbeLatestKeyMap() map[string]models.MonitorRecord {
|
||||||
|
var rows []MonitorProbeLatest
|
||||||
|
if err := s.db.Find(&rows).Error; err != nil {
|
||||||
|
log.Printf("GetProbeLatestKeyMap: %v", err)
|
||||||
|
return map[string]models.MonitorRecord{}
|
||||||
|
}
|
||||||
|
m := make(map[string]models.MonitorRecord, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
k := rows[i].WebsiteID + "\x00" + rows[i].URLID
|
||||||
|
m[k] = latestToRecord(&rows[i])
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupProbeHoursSince 近窗口内全部小时汇总,按站点、URL 分组(列表接口一次查出)
|
||||||
|
func (s *Storage) GroupProbeHoursSince(since time.Time) map[string]map[string][]MonitorProbeHour {
|
||||||
|
var rows []MonitorProbeHour
|
||||||
|
if err := s.db.Where("hour_at >= ?", since).Order("website_id, url_id, hour_at").Find(&rows).Error; err != nil {
|
||||||
|
log.Printf("GroupProbeHoursSince: %v", err)
|
||||||
|
return map[string]map[string][]MonitorProbeHour{}
|
||||||
|
}
|
||||||
|
out := make(map[string]map[string][]MonitorProbeHour)
|
||||||
|
for i := range rows {
|
||||||
|
h := rows[i]
|
||||||
|
if out[h.WebsiteID] == nil {
|
||||||
|
out[h.WebsiteID] = make(map[string][]MonitorProbeHour)
|
||||||
|
}
|
||||||
|
out[h.WebsiteID][h.URLID] = append(out[h.WebsiteID][h.URLID], h)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAllWebsiteDayRollups 自 since 日起各站每日汇总 + 90d 整体可用率(单条 SQL 聚合)
|
||||||
|
func (s *Storage) LoadAllWebsiteDayRollups(since time.Time) (map[string][]models.DailyStats, map[string]float64) {
|
||||||
|
sinceDay := time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, since.Location())
|
||||||
|
sinceStr := sinceDay.Format("2006-01-02")
|
||||||
|
type aggRow struct {
|
||||||
|
Wid string `gorm:"column:wid"`
|
||||||
|
D time.Time `gorm:"column:d"`
|
||||||
|
Pc int64 `gorm:"column:pc"`
|
||||||
|
Uc int64 `gorm:"column:uc"`
|
||||||
|
Ls int64 `gorm:"column:ls"`
|
||||||
|
}
|
||||||
|
var agg []aggRow
|
||||||
|
if err := s.db.Raw(`
|
||||||
|
SELECT website_id AS wid, stat_date AS d, SUM(probe_count) AS pc, SUM(up_count) AS uc, SUM(latency_sum) AS ls
|
||||||
|
FROM monitor_probe_day
|
||||||
|
WHERE stat_date >= ?
|
||||||
|
GROUP BY website_id, stat_date
|
||||||
|
ORDER BY website_id, stat_date
|
||||||
|
`, sinceStr).Scan(&agg).Error; err != nil {
|
||||||
|
log.Printf("LoadAllWebsiteDayRollups: %v", err)
|
||||||
|
return map[string][]models.DailyStats{}, map[string]float64{}
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := make(map[string][]models.DailyStats)
|
||||||
|
totP := make(map[string]int64)
|
||||||
|
totU := make(map[string]int64)
|
||||||
|
for _, r := range agg {
|
||||||
|
st := models.DailyStats{
|
||||||
|
Date: r.D,
|
||||||
|
TotalCount: int(r.Pc),
|
||||||
|
UpCount: int(r.Uc),
|
||||||
|
}
|
||||||
|
if r.Pc > 0 {
|
||||||
|
st.AvgLatency = r.Ls / r.Pc
|
||||||
|
st.Uptime = float64(r.Uc) / float64(r.Pc) * 100
|
||||||
|
}
|
||||||
|
daily[r.Wid] = append(daily[r.Wid], st)
|
||||||
|
totP[r.Wid] += r.Pc
|
||||||
|
totU[r.Wid] += r.Uc
|
||||||
|
}
|
||||||
|
uptime := make(map[string]float64)
|
||||||
|
for wid, p := range totP {
|
||||||
|
if p > 0 {
|
||||||
|
uptime[wid] = float64(totU[wid]) / float64(p) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return daily, uptime
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProbeHourTotals 某 URL 在时间窗口内汇总(来自小时表)
|
||||||
|
func (s *Storage) GetProbeHourTotals(websiteID, urlID string, since time.Time) (probeTotal int64, upTotal int64, latencySum int64) {
|
||||||
|
type row struct {
|
||||||
|
P int64
|
||||||
|
U int64
|
||||||
|
L int64
|
||||||
|
}
|
||||||
|
var r row
|
||||||
|
s.db.Model(&MonitorProbeHour{}).
|
||||||
|
Select("COALESCE(SUM(probe_count),0), COALESCE(SUM(up_count),0), COALESCE(SUM(latency_sum),0)").
|
||||||
|
Where("website_id = ? AND url_id = ? AND hour_at >= ?", websiteID, urlID, since).
|
||||||
|
Scan(&r)
|
||||||
|
return r.P, r.U, r.L
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHourlyStatsForURL 7 天折线用(已聚合)
|
||||||
|
func (s *Storage) GetHourlyStatsForURL(websiteID, urlID string, since time.Time) []models.HourlyStats {
|
||||||
|
rows := s.getProbeHourRows(websiteID, urlID, since)
|
||||||
|
out := make([]models.HourlyStats, 0, len(rows))
|
||||||
|
for _, h := range rows {
|
||||||
|
st := models.HourlyStats{
|
||||||
|
Hour: h.HourAt,
|
||||||
|
TotalCount: h.ProbeCount,
|
||||||
|
UpCount: h.UpCount,
|
||||||
|
}
|
||||||
|
if h.ProbeCount > 0 {
|
||||||
|
st.AvgLatency = h.LatencySum / int64(h.ProbeCount)
|
||||||
|
st.Uptime = float64(h.UpCount) / float64(h.ProbeCount) * 100
|
||||||
|
}
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWebsiteDailyAggregates 站点维度按日合并(多 URL 汇总到同一天)
|
||||||
|
func (s *Storage) GetWebsiteDailyAggregates(websiteID string, since time.Time) []models.DailyStats {
|
||||||
|
type aggRow struct {
|
||||||
|
D time.Time `gorm:"column:d"`
|
||||||
|
Pc int64 `gorm:"column:pc"`
|
||||||
|
Uc int64 `gorm:"column:uc"`
|
||||||
|
Ls int64 `gorm:"column:ls"`
|
||||||
|
}
|
||||||
|
var rows []aggRow
|
||||||
|
sinceDay := time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, since.Location())
|
||||||
|
s.db.Raw(`
|
||||||
|
SELECT stat_date AS d, SUM(probe_count) AS pc, SUM(up_count) AS uc, SUM(latency_sum) AS ls
|
||||||
|
FROM monitor_probe_day
|
||||||
|
WHERE website_id = ? AND stat_date >= ?
|
||||||
|
GROUP BY stat_date
|
||||||
|
ORDER BY stat_date ASC
|
||||||
|
`, websiteID, sinceDay.Format("2006-01-02")).Scan(&rows)
|
||||||
|
|
||||||
|
out := make([]models.DailyStats, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
st := models.DailyStats{
|
||||||
|
Date: r.D,
|
||||||
|
TotalCount: int(r.Pc),
|
||||||
|
UpCount: int(r.Uc),
|
||||||
|
}
|
||||||
|
if r.Pc > 0 {
|
||||||
|
st.AvgLatency = r.Ls / r.Pc
|
||||||
|
st.Uptime = float64(r.Uc) / float64(r.Pc) * 100
|
||||||
|
}
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWebsiteDayProbeTotals 整站在日期范围内探测次数汇总(用于 90 天可用率)
|
||||||
|
func (s *Storage) GetWebsiteDayProbeTotals(websiteID string, since time.Time) (probeTotal int64, upTotal int64) {
|
||||||
|
sinceDay := time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, since.Location())
|
||||||
|
type row struct {
|
||||||
|
P int64
|
||||||
|
U int64
|
||||||
|
}
|
||||||
|
var r row
|
||||||
|
s.db.Model(&MonitorProbeDay{}).
|
||||||
|
Select("COALESCE(SUM(probe_count),0), COALESCE(SUM(up_count),0)").
|
||||||
|
Where("website_id = ? AND stat_date >= ?", websiteID, sinceDay.Format("2006-01-02")).
|
||||||
|
Scan(&r)
|
||||||
|
return r.P, r.U
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) purgeProbeRollups() {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
now := time.Now()
|
||||||
|
dayCutoff := now.AddDate(0, 0, -cfg.Monitor.HistoryDays).Truncate(24 * time.Hour)
|
||||||
|
if err := s.db.Where("stat_date < ?", dayCutoff.Format("2006-01-02")).Delete(&MonitorProbeDay{}).Error; err != nil {
|
||||||
|
log.Printf("清理 monitor_probe_day: %v", err)
|
||||||
|
}
|
||||||
|
// 小时图保留约 10 天(覆盖 7 天曲线 + 余量)
|
||||||
|
hourCutoff := now.Add(-10 * 24 * time.Hour).Truncate(time.Hour)
|
||||||
|
if err := s.db.Where("hour_at < ?", hourCutoff).Delete(&MonitorProbeHour{}).Error; err != nil {
|
||||||
|
log.Printf("清理 monitor_probe_hour: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,302 +1,365 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"fmt"
|
||||||
"path/filepath"
|
"log"
|
||||||
"sync"
|
"os"
|
||||||
"time"
|
"strings"
|
||||||
|
"sync"
|
||||||
"mengyaping-backend/config"
|
"time"
|
||||||
"mengyaping-backend/models"
|
|
||||||
)
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
// Storage 数据存储
|
"gorm.io/gorm"
|
||||||
type Storage struct {
|
"gorm.io/gorm/logger"
|
||||||
dataPath string
|
|
||||||
mu sync.RWMutex
|
"mengyaping-backend/config"
|
||||||
websites []models.Website
|
"mengyaping-backend/models"
|
||||||
records map[string][]models.MonitorRecord // key: websiteID_urlID
|
)
|
||||||
groups []models.Group
|
|
||||||
}
|
// Storage 基于 MySQL 的持久化(站点 / 分类 / 检测记录)
|
||||||
|
type Storage struct {
|
||||||
var (
|
db *gorm.DB
|
||||||
store *Storage
|
}
|
||||||
once sync.Once
|
|
||||||
)
|
var (
|
||||||
|
store *Storage
|
||||||
// GetStorage 获取存储单例
|
once sync.Once
|
||||||
func GetStorage() *Storage {
|
)
|
||||||
once.Do(func() {
|
|
||||||
cfg := config.GetConfig()
|
// GetStorage 初始化数据库连接并迁移表结构
|
||||||
store = &Storage{
|
func GetStorage() *Storage {
|
||||||
dataPath: cfg.DataPath,
|
once.Do(func() {
|
||||||
websites: []models.Website{},
|
cfg := config.GetConfig()
|
||||||
records: make(map[string][]models.MonitorRecord),
|
dsn := cfg.DatabaseDSN()
|
||||||
groups: models.DefaultGroups,
|
|
||||||
}
|
gormLog := logger.Default.LogMode(logger.Warn)
|
||||||
store.ensureDataDir()
|
if os.Getenv("DB_DEBUG") == "1" {
|
||||||
store.load()
|
gormLog = logger.Default.LogMode(logger.Info)
|
||||||
})
|
}
|
||||||
return store
|
|
||||||
}
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: gormLog,
|
||||||
// ensureDataDir 确保数据目录存在
|
})
|
||||||
func (s *Storage) ensureDataDir() {
|
if err != nil {
|
||||||
os.MkdirAll(s.dataPath, 0755)
|
log.Fatalf("MySQL 连接失败: %v(请检查 DB_* / DB_DSN 环境变量)", err)
|
||||||
}
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
// load 加载数据
|
if err != nil {
|
||||||
func (s *Storage) load() {
|
log.Fatalf("MySQL sqlDB: %v", err)
|
||||||
s.loadWebsites()
|
}
|
||||||
s.loadRecords()
|
sqlDB.SetMaxIdleConns(8)
|
||||||
s.loadGroups()
|
sqlDB.SetMaxOpenConns(40)
|
||||||
}
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
// loadWebsites 加载网站数据
|
store = &Storage{db: db}
|
||||||
func (s *Storage) loadWebsites() {
|
if err := store.migrate(); err != nil {
|
||||||
filePath := filepath.Join(s.dataPath, "websites.json")
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
data, err := os.ReadFile(filePath)
|
}
|
||||||
if err != nil {
|
store.seedAdminTokenKV()
|
||||||
return
|
store.loadAndSyncAppConfig()
|
||||||
}
|
store.seedDefaultGroups()
|
||||||
json.Unmarshal(data, &s.websites)
|
log.Println("MySQL 存储已就绪: " + cfg.Database.Database + "@" + cfg.Database.Host)
|
||||||
s.migrateWebsiteGroups()
|
})
|
||||||
}
|
return store
|
||||||
|
}
|
||||||
// migrateWebsiteGroups 将旧的单分组字段迁移到多分组数组
|
|
||||||
func (s *Storage) migrateWebsiteGroups() {
|
func (s *Storage) migrate() error {
|
||||||
migrated := false
|
err := s.db.AutoMigrate(
|
||||||
for i := range s.websites {
|
&MonitorGroup{},
|
||||||
w := &s.websites[i]
|
&MonitorWebsite{},
|
||||||
if len(w.Groups) == 0 && w.Group != "" {
|
&MonitorWebsiteURL{},
|
||||||
w.Groups = []string{w.Group}
|
&MonitorWebsiteGroup{},
|
||||||
w.Group = ""
|
&MonitorProbeLatest{},
|
||||||
migrated = true
|
&MonitorProbeHour{},
|
||||||
}
|
&MonitorProbeDay{},
|
||||||
}
|
&MonitorKV{},
|
||||||
if migrated {
|
)
|
||||||
s.saveWebsites()
|
if err != nil {
|
||||||
}
|
return err
|
||||||
}
|
}
|
||||||
|
// 旧版逐条 monitor_checks 表体积极大,已弃用
|
||||||
// loadRecords 加载监控记录
|
if s.db.Migrator().HasTable("monitor_checks") {
|
||||||
func (s *Storage) loadRecords() {
|
if err := s.db.Migrator().DropTable("monitor_checks"); err != nil {
|
||||||
filePath := filepath.Join(s.dataPath, "records.json")
|
log.Printf("删除旧表 monitor_checks: %v(可手工 DROP)", err)
|
||||||
data, err := os.ReadFile(filePath)
|
} else {
|
||||||
if err != nil {
|
log.Println("已删除旧表 monitor_checks")
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
json.Unmarshal(data, &s.records)
|
return nil
|
||||||
|
}
|
||||||
// 清理过期记录
|
|
||||||
s.cleanOldRecords()
|
func (s *Storage) seedDefaultGroups() {
|
||||||
}
|
var n int64
|
||||||
|
s.db.Model(&MonitorGroup{}).Count(&n)
|
||||||
// loadGroups 加载分组
|
if n > 0 {
|
||||||
func (s *Storage) loadGroups() {
|
return
|
||||||
filePath := filepath.Join(s.dataPath, "groups.json")
|
}
|
||||||
data, err := os.ReadFile(filePath)
|
order := 0
|
||||||
if err != nil {
|
for _, g := range models.DefaultGroups {
|
||||||
// 使用默认分组
|
order++
|
||||||
s.groups = models.DefaultGroups
|
s.db.Create(&MonitorGroup{
|
||||||
s.saveGroups()
|
ID: g.ID,
|
||||||
return
|
Name: g.Name,
|
||||||
}
|
SortOrder: order,
|
||||||
json.Unmarshal(data, &s.groups)
|
})
|
||||||
}
|
}
|
||||||
|
log.Println("已写入默认分类 monitor_groups")
|
||||||
// saveWebsites 保存网站数据
|
}
|
||||||
func (s *Storage) saveWebsites() error {
|
|
||||||
filePath := filepath.Join(s.dataPath, "websites.json")
|
func ipsToJSON(ips []string) []byte {
|
||||||
data, err := json.MarshalIndent(s.websites, "", " ")
|
if ips == nil {
|
||||||
if err != nil {
|
ips = []string{}
|
||||||
return err
|
}
|
||||||
}
|
b, err := json.Marshal(ips)
|
||||||
return os.WriteFile(filePath, data, 0644)
|
if err != nil {
|
||||||
}
|
return []byte("[]")
|
||||||
|
}
|
||||||
// saveRecords 保存监控记录
|
return b
|
||||||
func (s *Storage) saveRecords() error {
|
}
|
||||||
filePath := filepath.Join(s.dataPath, "records.json")
|
|
||||||
data, err := json.MarshalIndent(s.records, "", " ")
|
func jsonToIPs(raw []byte) []string {
|
||||||
if err != nil {
|
if len(raw) == 0 {
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
return os.WriteFile(filePath, data, 0644)
|
var ips []string
|
||||||
}
|
if err := json.Unmarshal(raw, &ips); err != nil {
|
||||||
|
return nil
|
||||||
// saveGroups 保存分组
|
}
|
||||||
func (s *Storage) saveGroups() error {
|
return ips
|
||||||
filePath := filepath.Join(s.dataPath, "groups.json")
|
}
|
||||||
data, err := json.MarshalIndent(s.groups, "", " ")
|
|
||||||
if err != nil {
|
func rowToWebsite(w *MonitorWebsite) models.Website {
|
||||||
return err
|
groups := make([]string, 0, len(w.GroupLinks))
|
||||||
}
|
for _, l := range w.GroupLinks {
|
||||||
return os.WriteFile(filePath, data, 0644)
|
groups = append(groups, l.GroupID)
|
||||||
}
|
}
|
||||||
|
if len(groups) == 0 && w.LegacyGroup != "" {
|
||||||
// cleanOldRecords 清理过期记录
|
groups = []string{w.LegacyGroup}
|
||||||
func (s *Storage) cleanOldRecords() {
|
}
|
||||||
cfg := config.GetConfig()
|
urls := make([]models.URLInfo, 0, len(w.URLs))
|
||||||
cutoff := time.Now().AddDate(0, 0, -cfg.Monitor.HistoryDays)
|
for _, u := range w.URLs {
|
||||||
|
urls = append(urls, models.URLInfo{
|
||||||
for key, records := range s.records {
|
ID: u.URLID,
|
||||||
var newRecords []models.MonitorRecord
|
URL: u.URL,
|
||||||
for _, r := range records {
|
Remark: u.Remark,
|
||||||
if r.CheckedAt.After(cutoff) {
|
})
|
||||||
newRecords = append(newRecords, r)
|
}
|
||||||
}
|
return models.Website{
|
||||||
}
|
ID: w.ID,
|
||||||
s.records[key] = newRecords
|
Name: w.Name,
|
||||||
}
|
Groups: groups,
|
||||||
}
|
Group: "",
|
||||||
|
URLs: urls,
|
||||||
// GetWebsites 获取所有网站
|
IPAddresses: jsonToIPs(w.IPAddresses),
|
||||||
func (s *Storage) GetWebsites() []models.Website {
|
Favicon: w.Favicon,
|
||||||
s.mu.RLock()
|
Title: w.Title,
|
||||||
defer s.mu.RUnlock()
|
CreatedAt: w.CreatedAt,
|
||||||
|
UpdatedAt: w.UpdatedAt,
|
||||||
result := make([]models.Website, len(s.websites))
|
}
|
||||||
copy(result, s.websites)
|
}
|
||||||
return result
|
|
||||||
}
|
func websiteToRow(w *models.Website) *MonitorWebsite {
|
||||||
|
return &MonitorWebsite{
|
||||||
// GetWebsite 获取单个网站
|
ID: w.ID,
|
||||||
func (s *Storage) GetWebsite(id string) *models.Website {
|
Name: w.Name,
|
||||||
s.mu.RLock()
|
LegacyGroup: "",
|
||||||
defer s.mu.RUnlock()
|
Favicon: w.Favicon,
|
||||||
|
Title: w.Title,
|
||||||
for _, w := range s.websites {
|
IPAddresses: datatypes.JSON(ipsToJSON(w.IPAddresses)),
|
||||||
if w.ID == id {
|
CreatedAt: w.CreatedAt,
|
||||||
website := w
|
UpdatedAt: w.UpdatedAt,
|
||||||
return &website
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return nil
|
func (s *Storage) listWebsiteModels() ([]models.Website, error) {
|
||||||
}
|
var rows []MonitorWebsite
|
||||||
|
if err := s.db.Preload("URLs", func(db *gorm.DB) *gorm.DB {
|
||||||
// AddWebsite 添加网站
|
return db.Order("sort_order ASC, id ASC")
|
||||||
func (s *Storage) AddWebsite(website models.Website) error {
|
}).Preload("GroupLinks").Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||||
s.mu.Lock()
|
return nil, err
|
||||||
defer s.mu.Unlock()
|
}
|
||||||
|
out := make([]models.Website, len(rows))
|
||||||
s.websites = append(s.websites, website)
|
for i := range rows {
|
||||||
return s.saveWebsites()
|
out[i] = rowToWebsite(&rows[i])
|
||||||
}
|
}
|
||||||
|
return out, nil
|
||||||
// UpdateWebsite 更新网站
|
}
|
||||||
func (s *Storage) UpdateWebsite(website models.Website) error {
|
|
||||||
s.mu.Lock()
|
// GetWebsites 获取所有网站
|
||||||
defer s.mu.Unlock()
|
func (s *Storage) GetWebsites() []models.Website {
|
||||||
|
list, err := s.listWebsiteModels()
|
||||||
for i, w := range s.websites {
|
if err != nil {
|
||||||
if w.ID == website.ID {
|
log.Printf("GetWebsites: %v", err)
|
||||||
s.websites[i] = website
|
return nil
|
||||||
return s.saveWebsites()
|
}
|
||||||
}
|
return list
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
// GetWebsite 获取单个网站
|
||||||
|
func (s *Storage) GetWebsite(id string) *models.Website {
|
||||||
// DeleteWebsite 删除网站
|
var w MonitorWebsite
|
||||||
func (s *Storage) DeleteWebsite(id string) error {
|
if err := s.db.Preload("URLs", func(db *gorm.DB) *gorm.DB {
|
||||||
s.mu.Lock()
|
return db.Order("sort_order ASC, id ASC")
|
||||||
defer s.mu.Unlock()
|
}).Preload("GroupLinks").First(&w, "id = ?", id).Error; err != nil {
|
||||||
|
return nil
|
||||||
for i, w := range s.websites {
|
}
|
||||||
if w.ID == id {
|
m := rowToWebsite(&w)
|
||||||
s.websites = append(s.websites[:i], s.websites[i+1:]...)
|
return &m
|
||||||
// 删除相关记录
|
}
|
||||||
for key := range s.records {
|
|
||||||
if len(key) > len(id) && key[:len(id)] == id {
|
func (s *Storage) persistWebsiteFull(w models.Website) error {
|
||||||
delete(s.records, key)
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
}
|
row := websiteToRow(&w)
|
||||||
}
|
if err := tx.Save(row).Error; err != nil {
|
||||||
s.saveRecords()
|
return err
|
||||||
return s.saveWebsites()
|
}
|
||||||
}
|
if err := tx.Where("website_id = ?", w.ID).Delete(&MonitorWebsiteURL{}).Error; err != nil {
|
||||||
}
|
return err
|
||||||
return nil
|
}
|
||||||
}
|
if err := tx.Where("website_id = ?", w.ID).Delete(&MonitorWebsiteGroup{}).Error; err != nil {
|
||||||
|
return err
|
||||||
// AddRecord 添加监控记录
|
}
|
||||||
func (s *Storage) AddRecord(record models.MonitorRecord) error {
|
for i, u := range w.URLs {
|
||||||
s.mu.Lock()
|
if err := tx.Create(&MonitorWebsiteURL{
|
||||||
defer s.mu.Unlock()
|
WebsiteID: w.ID,
|
||||||
|
URLID: u.ID,
|
||||||
key := record.WebsiteID + "_" + record.URLID
|
URL: u.URL,
|
||||||
s.records[key] = append(s.records[key], record)
|
Remark: u.Remark,
|
||||||
|
SortOrder: i,
|
||||||
// 每100条记录保存一次
|
}).Error; err != nil {
|
||||||
if len(s.records[key])%100 == 0 {
|
return err
|
||||||
return s.saveRecords()
|
}
|
||||||
}
|
}
|
||||||
return nil
|
for _, gid := range w.Groups {
|
||||||
}
|
if err := tx.Create(&MonitorWebsiteGroup{WebsiteID: w.ID, GroupID: gid}).Error; err != nil {
|
||||||
|
return err
|
||||||
// GetRecords 获取监控记录
|
}
|
||||||
func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord {
|
}
|
||||||
s.mu.RLock()
|
return nil
|
||||||
defer s.mu.RUnlock()
|
})
|
||||||
|
}
|
||||||
key := websiteID + "_" + urlID
|
|
||||||
records := s.records[key]
|
// AddWebsite 添加网站
|
||||||
|
func (s *Storage) AddWebsite(website models.Website) error {
|
||||||
var result []models.MonitorRecord
|
return s.persistWebsiteFull(website)
|
||||||
for _, r := range records {
|
}
|
||||||
if r.CheckedAt.After(since) {
|
|
||||||
result = append(result, r)
|
// UpdateWebsite 更新网站
|
||||||
}
|
func (s *Storage) UpdateWebsite(website models.Website) error {
|
||||||
}
|
return s.persistWebsiteFull(website)
|
||||||
return result
|
}
|
||||||
}
|
|
||||||
|
// DeleteWebsite 删除网站
|
||||||
// GetLatestRecord 获取最新记录
|
func (s *Storage) DeleteWebsite(id string) error {
|
||||||
func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
s.mu.RLock()
|
_ = tx.Where("website_id = ?", id).Delete(&MonitorProbeLatest{})
|
||||||
defer s.mu.RUnlock()
|
_ = tx.Where("website_id = ?", id).Delete(&MonitorProbeHour{})
|
||||||
|
_ = tx.Where("website_id = ?", id).Delete(&MonitorProbeDay{})
|
||||||
key := websiteID + "_" + urlID
|
_ = tx.Where("website_id = ?", id).Delete(&MonitorWebsiteURL{})
|
||||||
records := s.records[key]
|
_ = tx.Where("website_id = ?", id).Delete(&MonitorWebsiteGroup{})
|
||||||
|
return tx.Where("id = ?", id).Delete(&MonitorWebsite{}).Error
|
||||||
if len(records) == 0 {
|
})
|
||||||
return nil
|
}
|
||||||
}
|
|
||||||
|
// GetGroups 获取所有分组
|
||||||
latest := records[len(records)-1]
|
func (s *Storage) GetGroups() []models.Group {
|
||||||
return &latest
|
var rows []MonitorGroup
|
||||||
}
|
s.db.Order("sort_order ASC, id ASC").Find(&rows)
|
||||||
|
out := make([]models.Group, len(rows))
|
||||||
// GetGroups 获取所有分组
|
for i, r := range rows {
|
||||||
func (s *Storage) GetGroups() []models.Group {
|
out[i] = models.Group{ID: r.ID, Name: r.Name}
|
||||||
s.mu.RLock()
|
}
|
||||||
defer s.mu.RUnlock()
|
return out
|
||||||
|
}
|
||||||
result := make([]models.Group, len(s.groups))
|
|
||||||
copy(result, s.groups)
|
// AddGroup 添加分组
|
||||||
return result
|
func (s *Storage) AddGroup(group models.Group) error {
|
||||||
}
|
var n int64
|
||||||
|
s.db.Model(&MonitorGroup{}).Count(&n)
|
||||||
// AddGroup 添加分组
|
return s.db.Create(&MonitorGroup{
|
||||||
func (s *Storage) AddGroup(group models.Group) error {
|
ID: group.ID,
|
||||||
s.mu.Lock()
|
Name: group.Name,
|
||||||
defer s.mu.Unlock()
|
SortOrder: int(n) + 1,
|
||||||
|
}).Error
|
||||||
s.groups = append(s.groups, group)
|
}
|
||||||
return s.saveGroups()
|
|
||||||
}
|
// GetGroupByID 按 ID 获取分组(无则 nil)
|
||||||
|
func (s *Storage) GetGroupByID(id string) *models.Group {
|
||||||
// SaveAll 保存所有数据
|
var r MonitorGroup
|
||||||
func (s *Storage) SaveAll() error {
|
if err := s.db.First(&r, "id = ?", id).Error; err != nil {
|
||||||
s.mu.Lock()
|
return nil
|
||||||
defer s.mu.Unlock()
|
}
|
||||||
|
g := models.Group{ID: r.ID, Name: r.Name}
|
||||||
if err := s.saveWebsites(); err != nil {
|
return &g
|
||||||
return err
|
}
|
||||||
}
|
|
||||||
if err := s.saveRecords(); err != nil {
|
// UpdateGroup 更新分组显示名称(ID 不变)
|
||||||
return err
|
func (s *Storage) UpdateGroup(id, name string) error {
|
||||||
}
|
name = strings.TrimSpace(name)
|
||||||
return s.saveGroups()
|
if name == "" {
|
||||||
}
|
return fmt.Errorf("分类名称不能为空")
|
||||||
|
}
|
||||||
|
res := s.db.Model(&MonitorGroup{}).Where("id = ?", id).Update("name", name)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("分类不存在")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroup 删除分组;从所有网站中移除该分类;若网站无剩余分类则归入删除后列表中的第一个分类。至少保留一个分类。
|
||||||
|
func (s *Storage) DeleteGroup(id string) error {
|
||||||
|
var total int64
|
||||||
|
s.db.Model(&MonitorGroup{}).Count(&total)
|
||||||
|
if total <= 1 {
|
||||||
|
return fmt.Errorf("至少保留一个分类")
|
||||||
|
}
|
||||||
|
|
||||||
|
var remain []MonitorGroup
|
||||||
|
s.db.Where("id != ?", id).Order("sort_order ASC, id ASC").Find(&remain)
|
||||||
|
if len(remain) == 0 {
|
||||||
|
return fmt.Errorf("至少保留一个分类")
|
||||||
|
}
|
||||||
|
fallbackID := remain[0].ID
|
||||||
|
|
||||||
|
var sites []MonitorWebsite
|
||||||
|
if err := s.db.Preload("GroupLinks").Find(&sites).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for i := range sites {
|
||||||
|
next := make([]string, 0)
|
||||||
|
for _, l := range sites[i].GroupLinks {
|
||||||
|
if l.GroupID != id {
|
||||||
|
next = append(next, l.GroupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(next) == 0 {
|
||||||
|
next = []string{fallbackID}
|
||||||
|
}
|
||||||
|
if err := tx.Where("website_id = ?", sites[i].ID).Delete(&MonitorWebsiteGroup{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, gid := range next {
|
||||||
|
if err := tx.Create(&MonitorWebsiteGroup{WebsiteID: sites[i].ID, GroupID: gid}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Delete(&MonitorGroup{}, "id = ?", id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAll 周期结束时清理过期汇总分区(日表按 history_days,小时表保留约 10 天)
|
||||||
|
func (s *Storage) SaveAll() error {
|
||||||
|
s.purgeProbeRollups()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
37
mengyaping-backend/utils/intranet.go
Normal file
37
mengyaping-backend/utils/intranet.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsIntranetURL 判断监控地址的主机是否为内网 / 本机 IP(直填 IP 的虚拟局域网站点)。
|
||||||
|
// 仅识别主机名为 IPv4/IPv6 字面量的情况;公网域名不在此列。
|
||||||
|
func IsIntranetURL(raw string) bool {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isIntranetIP(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIntranetIP(ip net.IP) bool {
|
||||||
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Go 1.17+ IsPrivate 已含 RFC1918、CGNAT 100.64/10、ULA 等;再兜底部分实现差异
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
24
mengyaping-backend/utils/intranet_test.go
Normal file
24
mengyaping-backend/utils/intranet_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsIntranetURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"http://10.0.0.233:8080/path", true},
|
||||||
|
{"https://192.168.1.1/", true},
|
||||||
|
{"http://127.0.0.1:3000", true},
|
||||||
|
{"http://[fd00::1]/", true},
|
||||||
|
{"https://1.1.1.1/", false},
|
||||||
|
{"https://example.com/", false},
|
||||||
|
{"http://nas.local:8080", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := IsIntranetURL(tc.raw)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("%q: got %v want %v", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
mengyaping-frontend/package-lock.json
generated
70
mengyaping-frontend/package-lock.json
generated
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@@ -58,7 +59,6 @@
|
|||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.28.6",
|
||||||
@@ -1633,7 +1633,6 @@
|
|||||||
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1675,7 +1674,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1818,7 +1816,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -1915,6 +1912,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2061,7 +2071,6 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3030,7 +3039,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3057,7 +3065,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3099,7 +3106,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3126,6 +3132,44 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
|
||||||
|
"integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz",
|
||||||
|
"integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.14.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -3196,6 +3240,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -3348,7 +3398,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -3470,7 +3519,6 @@
|
|||||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import Dashboard from './pages/Dashboard'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import RandomBackdrop from './components/RandomBackdrop'
|
||||||
|
import PublicDashboard from './pages/PublicDashboard'
|
||||||
|
import AdminPanel from './pages/AdminPanel'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return <Dashboard />
|
return (
|
||||||
|
<RandomBackdrop>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<PublicDashboard />} />
|
||||||
|
<Route path="/admin" element={<AdminPanel />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</RandomBackdrop>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
179
mengyaping-frontend/src/components/AdminGroupsPanel.jsx
Normal file
179
mengyaping-frontend/src/components/AdminGroupsPanel.jsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { getGroups, addGroup, updateGroup, deleteGroup } from '../services/api';
|
||||||
|
import { GROUPS_CHANGED_EVENT } from '../constants/events';
|
||||||
|
|
||||||
|
function notifyGroupsChanged() {
|
||||||
|
window.dispatchEvent(new CustomEvent(GROUPS_CHANGED_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminGroupsPanel() {
|
||||||
|
const [groups, setGroups] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const [saveBusy, setSaveBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const data = await getGroups();
|
||||||
|
setGroups(data || []);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || '加载分类失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function handleAdd(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
setAdding(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
await addGroup(name);
|
||||||
|
setNewName('');
|
||||||
|
await load();
|
||||||
|
notifyGroupsChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || '添加失败');
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(g) {
|
||||||
|
setEditingId(g.id);
|
||||||
|
setEditName(g.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditName('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingId) return;
|
||||||
|
const name = editName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
setSaveBusy(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
await updateGroup(editingId, { name });
|
||||||
|
cancelEdit();
|
||||||
|
await load();
|
||||||
|
notifyGroupsChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaveBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(g) {
|
||||||
|
if (!confirm(`确定删除分类「${g.name}」?\n使用该分类的站点将自动归入其他分类。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
await deleteGroup(g.id);
|
||||||
|
await load();
|
||||||
|
notifyGroupsChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white/90 backdrop-blur border border-emerald-100 rounded-xl shadow-sm p-4 sm:p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-800 mb-1">网站分类</h2>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
增删改分类后,列表与筛选会同步更新;删除分类时,原归属站点会合并到剩余的第一个分类。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleAdd} className="flex flex-col sm:flex-row gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="新分类名称"
|
||||||
|
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={adding || !newName.trim()}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{adding ? '添加中…' : '添加分类'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loading && !groups ? (
|
||||||
|
<p className="text-sm text-gray-500">加载中…</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-lg overflow-hidden">
|
||||||
|
{(groups || []).map((g) => (
|
||||||
|
<li key={g.id} className="flex flex-col sm:flex-row sm:items-center gap-2 px-3 py-2.5 bg-white text-sm">
|
||||||
|
{editingId === g.id ? (
|
||||||
|
<form onSubmit={handleSaveEdit} className="flex flex-1 flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
className="flex-1 min-w-[8rem] px-2 py-1.5 rounded border border-gray-200 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saveBusy || !editName.trim()}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-emerald-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={cancelEdit} className="text-xs px-2 py-1 rounded border border-gray-200">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="font-medium text-gray-800">{g.name}</span>
|
||||||
|
<code className="ml-2 text-[10px] text-gray-400 font-mono">{g.id}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startEdit(g)}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(g)}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-red-100 text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{err ? <p className="text-xs text-red-600 mt-3">{err}</p> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
mengyaping-frontend/src/components/RandomBackdrop.jsx
Normal file
57
mengyaping-frontend/src/components/RandomBackdrop.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const RANDOM_BG_API =
|
||||||
|
'https://randbg.api.smyhub.com/api/random?format=json';
|
||||||
|
|
||||||
|
/** 高斯模糊强度:10%(相对参考最大模糊半径,便于以后微调) */
|
||||||
|
const BLUR_PERCENT = 10;
|
||||||
|
const BLUR_REFERENCE_PX = 24;
|
||||||
|
|
||||||
|
export default function RandomBackdrop({ children }) {
|
||||||
|
const [imageUrl, setImageUrl] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(RANDOM_BG_API);
|
||||||
|
const data = await res.json();
|
||||||
|
const url = data?.url;
|
||||||
|
if (!cancelled && typeof url === 'string' && url.length > 0) {
|
||||||
|
setImageUrl(url);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 失败时仅用遮罩渐变,不阻塞页面 */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const blurPx = (BLUR_REFERENCE_PX * BLUR_PERCENT) / 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-0 overflow-hidden pointer-events-none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${imageUrl})`,
|
||||||
|
filter: `blur(${blurPx}px)`,
|
||||||
|
transform: 'scale(1.08)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{/* 浅色罩层:保证前景可读,并保留原站绿色系氛围 */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-emerald-50/82 via-green-50/78 to-teal-50/82" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 min-h-screen">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -133,12 +133,6 @@ export default function StatsCard({ websites }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center mt-3 text-xs text-gray-500">
|
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
每5分钟检测一次
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { formatLatency, formatUptime, formatTime, getStatusColor, getUptimeColor
|
|||||||
|
|
||||||
const HISTORY_DAYS = 90;
|
const HISTORY_DAYS = 90;
|
||||||
|
|
||||||
|
const INTRANET_FALLBACK_ICON =
|
||||||
|
'https://api.iconify.design/mdi/lan-connect.svg?color=%23059669';
|
||||||
|
const FAVICON_PROXY_BASE = 'https://favicon.smyhub.com/api/favicon?url=';
|
||||||
|
|
||||||
function getBarColor(bar) {
|
function getBarColor(bar) {
|
||||||
if (bar.uptime === null) return 'bg-gray-200';
|
if (bar.uptime === null) return 'bg-gray-200';
|
||||||
if (bar.uptime >= 99) return 'bg-emerald-400';
|
if (bar.uptime >= 99) return 'bg-emerald-400';
|
||||||
@@ -19,7 +23,7 @@ function getBarHoverColor(bar) {
|
|||||||
return 'hover:bg-red-600';
|
return 'hover:bg-red-600';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete, readOnly = false }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [hoveredBar, setHoveredBar] = useState(null);
|
const [hoveredBar, setHoveredBar] = useState(null);
|
||||||
|
|
||||||
@@ -28,6 +32,10 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
|||||||
const statusCode = primaryStatus?.current_state?.status_code ?? 0;
|
const statusCode = primaryStatus?.current_state?.status_code ?? 0;
|
||||||
const latency = primaryStatus?.current_state?.latency ?? 0;
|
const latency = primaryStatus?.current_state?.latency ?? 0;
|
||||||
const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
|
const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
|
||||||
|
const isIntranet = website.is_intranet === true;
|
||||||
|
const cardIconSrc = isIntranet
|
||||||
|
? (website.website?.favicon || INTRANET_FALLBACK_ICON)
|
||||||
|
: (primaryUrl ? `${FAVICON_PROXY_BASE}${encodeURIComponent(primaryUrl)}` : null);
|
||||||
|
|
||||||
const dailyBars = useMemo(() => {
|
const dailyBars = useMemo(() => {
|
||||||
const bars = [];
|
const bars = [];
|
||||||
@@ -81,9 +89,9 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
|||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
|
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
|
||||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center overflow-hidden flex-shrink-0">
|
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||||
{primaryUrl ? (
|
{cardIconSrc ? (
|
||||||
<img
|
<img
|
||||||
src={`https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(primaryUrl)}`}
|
src={cardIconSrc}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-7 h-7 object-contain drop-shadow"
|
className="w-7 h-7 object-contain drop-shadow"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
@@ -93,8 +101,8 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
<span
|
||||||
className={`text-base font-bold text-emerald-600 ${primaryUrl ? 'hidden' : ''}`}
|
className={`text-base font-bold text-emerald-600 ${cardIconSrc ? 'hidden' : ''}`}
|
||||||
style={{ display: primaryUrl ? 'none' : 'flex' }}
|
style={{ display: cardIconSrc ? 'none' : 'flex' }}
|
||||||
>
|
>
|
||||||
{website.website?.name?.[0] || '?'}
|
{website.website?.name?.[0] || '?'}
|
||||||
</span>
|
</span>
|
||||||
@@ -172,7 +180,7 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* IP 地址(IPv4 / IPv6 分开显示) */}
|
{/* IP 地址(IPv4 / IPv6 分开显示) */}
|
||||||
{website.website?.ip_addresses?.length > 0 && (() => {
|
{!isIntranet && website.website?.ip_addresses?.length > 0 && (() => {
|
||||||
const ipv4 = website.website.ip_addresses.filter(ip => !ip.includes(':'));
|
const ipv4 = website.website.ip_addresses.filter(ip => !ip.includes(':'));
|
||||||
const ipv6 = 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) && (
|
return (ipv4.length > 0 || ipv6.length > 0) && (
|
||||||
@@ -222,42 +230,47 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮(仅管理后台) */}
|
||||||
<div className="flex items-center justify-between mt-3">
|
<div className={`flex items-center mt-3 ${readOnly ? 'justify-start' : 'justify-between'}`}>
|
||||||
<span className="text-[10px] text-gray-400">
|
<span className="text-[10px] text-gray-400">
|
||||||
{formatTime(website.last_checked)}
|
{formatTime(website.last_checked)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex space-x-1.5">
|
{!readOnly && (
|
||||||
<button
|
<div className="flex space-x-1.5">
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation();
|
type="button"
|
||||||
onRefresh?.(website.website?.id);
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
className="px-2.5 py-1 text-[10px] font-medium text-emerald-600 bg-emerald-50 rounded-md hover:bg-emerald-100 transition-colors"
|
onRefresh?.(website.website?.id);
|
||||||
>
|
}}
|
||||||
检测
|
className="px-2.5 py-1 text-[10px] font-medium text-emerald-600 bg-emerald-50 rounded-md hover:bg-emerald-100 transition-colors"
|
||||||
</button>
|
>
|
||||||
<button
|
检测
|
||||||
onClick={(e) => {
|
</button>
|
||||||
e.stopPropagation();
|
<button
|
||||||
onEdit?.(website);
|
type="button"
|
||||||
}}
|
onClick={(e) => {
|
||||||
className="px-2.5 py-1 text-[10px] font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
e.stopPropagation();
|
||||||
>
|
onEdit?.(website);
|
||||||
编辑
|
}}
|
||||||
</button>
|
className="px-2.5 py-1 text-[10px] font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
||||||
<button
|
>
|
||||||
onClick={(e) => {
|
编辑
|
||||||
e.stopPropagation();
|
</button>
|
||||||
if (confirm('确定要删除这个网站吗?')) {
|
<button
|
||||||
onDelete?.(website.website?.id);
|
type="button"
|
||||||
}
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
className="px-2.5 py-1 text-[10px] font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 transition-colors"
|
if (confirm('确定要删除这个网站吗?')) {
|
||||||
>
|
onDelete?.(website.website?.id);
|
||||||
删除
|
}
|
||||||
</button>
|
}}
|
||||||
</div>
|
className="px-2.5 py-1 text-[10px] font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
2
mengyaping-frontend/src/constants/events.js
Normal file
2
mengyaping-frontend/src/constants/events.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/** 分类增删改后广播,便于监控页立即刷新分组与站点 */
|
||||||
|
export const GROUPS_CHANGED_EVENT = 'mengyaping:groups-changed';
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
|||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
206
mengyaping-frontend/src/pages/AdminPanel.jsx
Normal file
206
mengyaping-frontend/src/pages/AdminPanel.jsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Dashboard from './Dashboard';
|
||||||
|
import {
|
||||||
|
verifyAdminToken,
|
||||||
|
setStoredAdminToken,
|
||||||
|
getStoredAdminToken,
|
||||||
|
getAdminConfig,
|
||||||
|
updateAdminMonitorConfig,
|
||||||
|
} from '../services/api';
|
||||||
|
import AdminGroupsPanel from '../components/AdminGroupsPanel';
|
||||||
|
|
||||||
|
const INTERVAL_OPTIONS = [
|
||||||
|
{ minutes: 10, label: '10 分钟' },
|
||||||
|
{ minutes: 20, label: '20 分钟' },
|
||||||
|
{ minutes: 30, label: '30 分钟' },
|
||||||
|
{ minutes: 60, label: '1 小时' },
|
||||||
|
{ minutes: 180, label: '3 小时' },
|
||||||
|
{ minutes: 360, label: '6 小时' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AdminScheduleToolbar({ onLogout }) {
|
||||||
|
const [intervalMinutes, setIntervalMinutes] = useState(30);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const c = await getAdminConfig();
|
||||||
|
setIntervalMinutes(c.interval_minutes);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || '加载配置失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function handleSave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
await updateAdminMonitorConfig({ interval_minutes: intervalMinutes });
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white/90 backdrop-blur border border-emerald-100 rounded-xl shadow-sm p-4 sm:p-5">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-800">全局检测周期</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
所有站点共用同一轮询间隔,保存后立即调整后台定时器(仍会先跑完上一轮逻辑)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-xs px-3 py-1.5 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
公开页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-gray-500">加载中…</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSave} className="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-gray-600 flex-1 sm:max-w-xs">
|
||||||
|
检测间隔
|
||||||
|
<select
|
||||||
|
value={intervalMinutes}
|
||||||
|
onChange={(e) => setIntervalMinutes(Number(e.target.value))}
|
||||||
|
className="mt-0.5 px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-800 bg-white focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||||
|
>
|
||||||
|
{INTERVAL_OPTIONS.map((o) => (
|
||||||
|
<option key={o.minutes} value={o.minutes}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? '保存中…' : '保存周期'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{err ? <p className="text-xs text-red-600 mt-3">{err}</p> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPanel() {
|
||||||
|
const [authed, setAuthed] = useState(() => !!getStoredAdminToken());
|
||||||
|
const [tokenInput, setTokenInput] = useState('');
|
||||||
|
const [loginErr, setLoginErr] = useState('');
|
||||||
|
const [loggingIn, setLoggingIn] = useState(false);
|
||||||
|
|
||||||
|
async function handleLogin(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = tokenInput.trim();
|
||||||
|
if (!t) {
|
||||||
|
setLoginErr('请输入管理员令牌');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoggingIn(true);
|
||||||
|
setLoginErr('');
|
||||||
|
try {
|
||||||
|
await verifyAdminToken(t);
|
||||||
|
setStoredAdminToken(t);
|
||||||
|
setAuthed(true);
|
||||||
|
setTokenInput('');
|
||||||
|
} catch (err) {
|
||||||
|
setLoginErr(err.message || '登录失败');
|
||||||
|
} finally {
|
||||||
|
setLoggingIn(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
setStoredAdminToken(null);
|
||||||
|
setAuthed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authed) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md bg-white rounded-2xl shadow-lg border border-gray-100 p-6 sm:p-8">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-11 h-11 rounded-xl overflow-hidden shadow">
|
||||||
|
<img src="/logo.png" alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-gray-900">萌芽Ping · 管理后台</h1>
|
||||||
|
<p className="text-xs text-gray-500">请输入与服务端 ADMIN_TOKEN 一致的口令</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<label className="block text-xs font-medium text-gray-600">
|
||||||
|
管理员令牌
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={tokenInput}
|
||||||
|
onChange={(e) => setTokenInput(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-lg border border-gray-200 text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||||
|
placeholder="环境变量 ADMIN_TOKEN"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{loginErr ? <p className="text-xs text-red-600">{loginErr}</p> : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loggingIn}
|
||||||
|
className="w-full py-2.5 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loggingIn ? '验证中…' : '进入管理'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center mt-5">
|
||||||
|
<Link to="/" className="text-sm text-emerald-700 hover:underline">
|
||||||
|
← 返回公开页
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dashboard
|
||||||
|
readOnly={false}
|
||||||
|
topSlot={
|
||||||
|
<>
|
||||||
|
<AdminScheduleToolbar onLogout={handleLogout} />
|
||||||
|
<div className="mt-4">
|
||||||
|
<AdminGroupsPanel />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,107 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import WebsiteCard from '../components/WebsiteCard';
|
import WebsiteCard from '../components/WebsiteCard';
|
||||||
import WebsiteModal from '../components/WebsiteModal';
|
import WebsiteModal from '../components/WebsiteModal';
|
||||||
import StatsCard from '../components/StatsCard';
|
import StatsCard from '../components/StatsCard';
|
||||||
import { useAutoRefresh } from '../hooks/useMonitor';
|
import { useAutoRefresh } from '../hooks/useMonitor';
|
||||||
import { getWebsites, deleteWebsite, checkWebsiteNow, getGroups } from '../services/api';
|
import {
|
||||||
|
getWebsites,
|
||||||
|
deleteWebsite,
|
||||||
|
checkWebsiteNow,
|
||||||
|
getGroups,
|
||||||
|
getMonitorSettings,
|
||||||
|
verifyAdminToken,
|
||||||
|
setStoredAdminToken,
|
||||||
|
} from '../services/api';
|
||||||
|
import { GROUPS_CHANGED_EVENT } from '../constants/events';
|
||||||
|
|
||||||
|
const LOGO_TAP_WINDOW_MS = 2000;
|
||||||
|
const LOGO_TAPS_TO_OPEN = 5;
|
||||||
|
|
||||||
|
export default function Dashboard({ readOnly = false, topSlot = null }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const logoTapCountRef = useRef(0);
|
||||||
|
const logoTapResetTimerRef = useRef(null);
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editData, setEditData] = useState(null);
|
const [editData, setEditData] = useState(null);
|
||||||
const [selectedGroup, setSelectedGroup] = useState('all');
|
const [selectedGroup, setSelectedGroup] = useState('all');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
const [adminGateOpen, setAdminGateOpen] = useState(false);
|
||||||
|
const [adminGateToken, setAdminGateToken] = useState('');
|
||||||
|
const [adminGateErr, setAdminGateErr] = useState('');
|
||||||
|
const [adminGateBusy, setAdminGateBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const t = logoTapResetTimerRef.current;
|
||||||
|
if (t != null) {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogoTap = () => {
|
||||||
|
if (!readOnly) return;
|
||||||
|
if (logoTapResetTimerRef.current != null) {
|
||||||
|
clearTimeout(logoTapResetTimerRef.current);
|
||||||
|
}
|
||||||
|
logoTapCountRef.current += 1;
|
||||||
|
if (logoTapCountRef.current >= LOGO_TAPS_TO_OPEN) {
|
||||||
|
logoTapCountRef.current = 0;
|
||||||
|
logoTapResetTimerRef.current = null;
|
||||||
|
setAdminGateOpen(true);
|
||||||
|
setAdminGateErr('');
|
||||||
|
setAdminGateToken('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logoTapResetTimerRef.current = setTimeout(() => {
|
||||||
|
logoTapCountRef.current = 0;
|
||||||
|
logoTapResetTimerRef.current = null;
|
||||||
|
}, LOGO_TAP_WINDOW_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminGateSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = adminGateToken.trim();
|
||||||
|
if (!t) {
|
||||||
|
setAdminGateErr('请输入令牌');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdminGateBusy(true);
|
||||||
|
setAdminGateErr('');
|
||||||
|
try {
|
||||||
|
await verifyAdminToken(t);
|
||||||
|
setStoredAdminToken(t);
|
||||||
|
setAdminGateOpen(false);
|
||||||
|
navigate('/admin');
|
||||||
|
} catch (err) {
|
||||||
|
setAdminGateErr(err.message || '验证失败');
|
||||||
|
} finally {
|
||||||
|
setAdminGateBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 获取网站数据
|
// 获取网站数据
|
||||||
const fetchData = useCallback(() => getWebsites(), []);
|
const fetchData = useCallback(() => getWebsites(), []);
|
||||||
const { data: websites, loading, error, refresh } = useAutoRefresh(fetchData, 30000);
|
const { data: websites, loading, error, refresh } = useAutoRefresh(fetchData, 30000);
|
||||||
|
|
||||||
// 获取分组数据
|
// 获取分组数据
|
||||||
const fetchGroups = useCallback(() => getGroups(), []);
|
const fetchGroups = useCallback(() => getGroups(), []);
|
||||||
const { data: groups } = useAutoRefresh(fetchGroups, 60000);
|
const { data: groups, refresh: refreshGroups } = useAutoRefresh(fetchGroups, 60000);
|
||||||
|
|
||||||
|
const fetchMonitorSettings = useCallback(() => getMonitorSettings(), []);
|
||||||
|
const { data: monitorSettings } = useAutoRefresh(fetchMonitorSettings, 120000);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onGroupsChanged = () => {
|
||||||
|
refreshGroups();
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
window.addEventListener(GROUPS_CHANGED_EVENT, onGroupsChanged);
|
||||||
|
return () => window.removeEventListener(GROUPS_CHANGED_EVENT, onGroupsChanged);
|
||||||
|
}, [refreshGroups, refresh]);
|
||||||
|
|
||||||
// 处理添加网站
|
// 处理添加网站
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
@@ -102,44 +186,67 @@ export default function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-green-50 to-teal-50">
|
<div className="min-h-screen">
|
||||||
{/* 顶部导航 */}
|
{/* 顶部导航 */}
|
||||||
<header className="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-40">
|
<header className="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-40">
|
||||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-10 h-10 rounded-xl overflow-hidden shadow-lg shadow-emerald-200">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogoTap}
|
||||||
|
className={`w-10 h-10 rounded-xl overflow-hidden shadow-lg shadow-emerald-200 border-0 p-0 bg-transparent ${readOnly ? 'cursor-pointer' : ''}`}
|
||||||
|
aria-label="萌芽Ping"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="萌芽Ping"
|
alt="萌芽Ping"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover pointer-events-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-green-600 bg-clip-text text-transparent">
|
<h1 className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-green-600 bg-clip-text text-transparent">
|
||||||
萌芽Ping
|
萌芽Ping
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-gray-500">网站监控面板</p>
|
{monitorSettings?.interval_minutes ? (
|
||||||
|
<p className="text-[11px] text-emerald-700/80 mt-0.5">
|
||||||
|
检测周期:每 {monitorSettings.interval_minutes} 分钟
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 添加按钮 */}
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{readOnly ? null : (
|
||||||
onClick={handleAdd}
|
<>
|
||||||
className="flex items-center px-4 py-2 bg-gradient-to-r from-emerald-500 to-green-500 text-white rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all shadow-md shadow-emerald-200"
|
<Link
|
||||||
>
|
to="/"
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
className="hidden sm:flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
>
|
||||||
</svg>
|
公开页
|
||||||
<span className="hidden sm:inline">添加监控</span>
|
</Link>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex items-center px-4 py-2 bg-gradient-to-r from-emerald-500 to-green-500 text-white rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all shadow-md shadow-emerald-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">添加监控</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 主内容区 */}
|
{/* 主内容区 */}
|
||||||
<main className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<main className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
{topSlot ? <div className="mb-6">{topSlot}</div> : null}
|
||||||
|
|
||||||
{/* 统计概览 */}
|
{/* 统计概览 */}
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
<StatsCard websites={websites} />
|
<StatsCard websites={websites} />
|
||||||
@@ -225,14 +332,18 @@ export default function Dashboard() {
|
|||||||
<p className="text-gray-500 mb-4">
|
<p className="text-gray-500 mb-4">
|
||||||
{searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'}
|
{searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'}
|
||||||
</p>
|
</p>
|
||||||
{!searchTerm && (
|
{!searchTerm && !readOnly && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
|
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
|
||||||
>
|
>
|
||||||
添加第一个网站
|
添加第一个网站
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!searchTerm && readOnly && (
|
||||||
|
<p className="text-sm text-gray-400">站点由管理员在「管理后台」维护</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -250,9 +361,10 @@ export default function Dashboard() {
|
|||||||
<WebsiteCard
|
<WebsiteCard
|
||||||
key={website.website?.id}
|
key={website.website?.id}
|
||||||
website={website}
|
website={website}
|
||||||
onRefresh={handleRefresh}
|
readOnly={readOnly}
|
||||||
onEdit={handleEdit}
|
onRefresh={readOnly ? undefined : handleRefresh}
|
||||||
onDelete={handleDelete}
|
onEdit={readOnly ? undefined : handleEdit}
|
||||||
|
onDelete={readOnly ? undefined : handleDelete}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -269,12 +381,61 @@ export default function Dashboard() {
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* 添加/编辑弹窗 */}
|
{/* 添加/编辑弹窗 */}
|
||||||
<WebsiteModal
|
{!readOnly && (
|
||||||
isOpen={modalOpen}
|
<WebsiteModal
|
||||||
onClose={() => setModalOpen(false)}
|
isOpen={modalOpen}
|
||||||
onSuccess={refresh}
|
onClose={() => setModalOpen(false)}
|
||||||
editData={editData}
|
onSuccess={refresh}
|
||||||
/>
|
editData={editData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adminGateOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="admin-gate-title"
|
||||||
|
onClick={() => setAdminGateOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm bg-white rounded-2xl shadow-xl border border-gray-100 p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="admin-gate-title" className="text-base font-semibold text-gray-900">
|
||||||
|
管理员验证
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">请输入与服务端配置一致的管理员令牌</p>
|
||||||
|
<form onSubmit={handleAdminGateSubmit} className="mt-4 space-y-3">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={adminGateToken}
|
||||||
|
onChange={(e) => setAdminGateToken(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||||
|
placeholder="admin_token"
|
||||||
|
/>
|
||||||
|
{adminGateErr ? <p className="text-xs text-red-600">{adminGateErr}</p> : null}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdminGateOpen(false)}
|
||||||
|
className="px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={adminGateBusy}
|
||||||
|
className="px-3 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{adminGateBusy ? '验证中…' : '进入管理'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
mengyaping-frontend/src/pages/PublicDashboard.jsx
Normal file
5
mengyaping-frontend/src/pages/PublicDashboard.jsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Dashboard from './Dashboard';
|
||||||
|
|
||||||
|
export default function PublicDashboard() {
|
||||||
|
return <Dashboard readOnly />;
|
||||||
|
}
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
// API服务
|
// API服务
|
||||||
// 根据环境变量判断使用哪个 API 地址
|
// 根据环境变量判断使用哪个 API 地址
|
||||||
const API_BASE = import.meta.env.PROD
|
const API_BASE = import.meta.env.PROD
|
||||||
? 'https://ping.api.shumengya.top/api' // 生产环境
|
? 'https://ping.api.shumengya.top/api' // 生产环境
|
||||||
: 'http://localhost:8080/api'; // 开发环境
|
: 'http://localhost:8080/api'; // 开发环境
|
||||||
|
|
||||||
// 通用请求方法
|
const ADMIN_TOKEN_KEY = 'mengyaping_admin_token';
|
||||||
|
|
||||||
|
export function getStoredAdminToken() {
|
||||||
|
return localStorage.getItem(ADMIN_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredAdminToken(token) {
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJsonResponse(response) {
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用请求(公开读接口)
|
||||||
async function request(url, options = {}) {
|
async function request(url, options = {}) {
|
||||||
const response = await fetch(`${API_BASE}${url}`, {
|
const response = await fetch(`${API_BASE}${url}`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -13,16 +32,62 @@ async function request(url, options = {}) {
|
|||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await parseJsonResponse(response);
|
||||||
|
|
||||||
if (data.code !== 0) {
|
if (data.code !== 0) {
|
||||||
throw new Error(data.message || '请求失败');
|
throw new Error(data.message || '请求失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function adminRequest(url, options = {}) {
|
||||||
|
const token = getStoredAdminToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('请先登录管理后台');
|
||||||
|
}
|
||||||
|
return request(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
'X-Admin-Token': token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验管理员令牌(登录前调用,不落库) */
|
||||||
|
export async function verifyAdminToken(token) {
|
||||||
|
const response = await fetch(`${API_BASE}/admin/config`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Admin-Token': token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await parseJsonResponse(response);
|
||||||
|
if (data.code !== 0) {
|
||||||
|
throw new Error(data.message || '令牌无效');
|
||||||
|
}
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公开:全局检测周期
|
||||||
|
export async function getMonitorSettings() {
|
||||||
|
return request('/monitor/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理端:完整监控配置
|
||||||
|
export async function getAdminConfig() {
|
||||||
|
return adminRequest('/admin/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminMonitorConfig(payload) {
|
||||||
|
return adminRequest('/admin/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 获取所有网站状态
|
// 获取所有网站状态
|
||||||
export async function getWebsites() {
|
export async function getWebsites() {
|
||||||
return request('/websites');
|
return request('/websites');
|
||||||
@@ -35,7 +100,7 @@ export async function getWebsite(id) {
|
|||||||
|
|
||||||
// 创建网站
|
// 创建网站
|
||||||
export async function createWebsite(data) {
|
export async function createWebsite(data) {
|
||||||
return request('/websites', {
|
return adminRequest('/websites', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
@@ -43,7 +108,7 @@ export async function createWebsite(data) {
|
|||||||
|
|
||||||
// 更新网站
|
// 更新网站
|
||||||
export async function updateWebsite(id, data) {
|
export async function updateWebsite(id, data) {
|
||||||
return request(`/websites/${id}`, {
|
return adminRequest(`/websites/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
@@ -51,14 +116,14 @@ export async function updateWebsite(id, data) {
|
|||||||
|
|
||||||
// 删除网站
|
// 删除网站
|
||||||
export async function deleteWebsite(id) {
|
export async function deleteWebsite(id) {
|
||||||
return request(`/websites/${id}`, {
|
return adminRequest(`/websites/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 立即检测网站
|
// 立即检测网站
|
||||||
export async function checkWebsiteNow(id) {
|
export async function checkWebsiteNow(id) {
|
||||||
return request(`/websites/${id}/check`, {
|
return adminRequest(`/websites/${id}/check`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,10 +133,30 @@ export async function getGroups() {
|
|||||||
return request('/groups');
|
return request('/groups');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取单个分组
|
||||||
|
export async function getGroup(id) {
|
||||||
|
return request(`/groups/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 添加分组
|
// 添加分组
|
||||||
export async function addGroup(name) {
|
export async function addGroup(name) {
|
||||||
return request('/groups', {
|
return adminRequest('/groups', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新分组
|
||||||
|
export async function updateGroup(id, payload) {
|
||||||
|
return adminRequest(`/groups/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除分组
|
||||||
|
export async function deleteGroup(id) {
|
||||||
|
return adminRequest(`/groups/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user