Files
aiops/backend/main.go
T
LoganandClaude 471353e3d7 feat: 监控告警体系统一重构 + 自动化任务调度引擎 + 监控大屏拆分
告警中心
- 新增统一告警(alert_unified)、监控告警规则(monitor_alert_rule)、指标目录(alert_metric_catalog)
- 通知策略与消息分发(alert_notify_policy/dispatch/message)、静默规则
- 告警引擎按阈值/SNMP 拆分(alert_engine_monitor/snmp)

监控采集
- SNMP 常量/模板与历史指标(snmp templates/constants、snmp_metrics_history、snmp_collect)
- 中间件监控 Redis 采集与种子(middleware_monitor_redis/seed)
- 站点探测 pkg(siteprobe) + 站点视图(site_view)

自动化任务
- cron 调度器(cronscheduler)、脚本运行器(scriptrunner)、系统任务(systask)及运行记录(cron_job_run、system_task)

审计与凭证
- 审计设置(audit_setting) + 历史清理(auditcleaner)

前端
- 监控页面(host/middleware/site/snmp)拆分为子模块并大屏化
- 告警中心多 Tab(规则/指标目录/通知策略/静默)、任务管理组件重构、Agent CLI Markdown 渲染
- 移除旧脚本卡片/详情/日志组件,统一为 ScriptTable/ScriptCodeEditor/TerminalOutput

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 23:44:45 +08:00

504 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"ezops/controller"
"ezops/router"
"ezops/config"
"ezops/model"
"ezops/seed"
"ezops/pkg/appdir"
"ezops/pkg/auditcleaner"
"ezops/pkg/database"
snmppkg "ezops/pkg/snmp"
"ezops/pkg/cronscheduler"
"ezops/pkg/systask"
"ezops/pkg/logger"
ezredis "ezops/pkg/redis"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"go.uber.org/zap"
)
// 版本信息(通过编译时 ldflags 注入)// Logan.Li
var (
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)
// CLI 参数定义 // Logan.Li
var (
flagPort = flag.String("port", "", "服务端口 (默认: 8080)")
flagConfig = flag.String("config", "", "配置文件路径 (默认: config.yaml)")
flagMode = flag.String("mode", "", "运行模式: local (SQLite+redka), development, production, release")
flagLogLevel = flag.String("log-level", "", "日志级别: debug, info, warn, error")
// 数据库参数
flagDBDriver = flag.String("db-driver", "", "数据库驱动: sqlite, mysql, postgres (默认: sqlite)")
flagDBHost = flag.String("db-host", "", "数据库主机")
flagDBPort = flag.Int("db-port", 0, "数据库端口")
flagDBUser = flag.String("db-user", "", "数据库用户名")
flagDBPassword = flag.String("db-password", "", "数据库密码")
flagDBName = flag.String("db-name", "", "数据库名 / SQLite 文件路径")
flagDBCharset = flag.String("db-charset", "", "数据库字符集")
// Redis 参数
flagRedisHost = flag.String("redis-host", "", "Redis 主机")
flagRedisPort = flag.Int("redis-port", 0, "Redis 端口")
flagRedisPassword = flag.String("redis-password", "", "Redis 密码")
flagRedisDB = flag.Int("redis-db", 0, "Redis 数据库编号")
flagRedisDriver = flag.String("redis-driver", "", "Redis 驱动: redis (外部Redis), redka (内嵌,需CGO)")
// JWT 参数
flagJWTSecret = flag.String("jwt-secret", "", "JWT 密钥")
flagJWTExpire = flag.Int("jwt-expire", 0, "JWT 过期时间 (小时)")
// 显示版本
flagVersion = flag.Bool("version", false, "显示版本信息")
)
// @title AIOps运维平台 API
// @version 1.0
// @description AIOps智能运维平台后端API文档
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.url http://www.swagger.io/support
// @contact.email support@swagger.io
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
// printVersion 输出编译时注入的版本信息 // Logan.Li
func printVersion() {
fmt.Printf("AIOps 智能运维平台\n")
fmt.Printf(" 版本: %s\n", Version)
fmt.Printf(" 构建: %s\n", BuildTime)
fmt.Printf(" 提交: %s\n", GitCommit)
}
func main() {
// 支持 ./aiops version 子命令(无需启动服务)// Logan.Li
if len(os.Args) > 1 {
switch os.Args[1] {
case "version", "-v", "--version":
printVersion()
os.Exit(0)
}
}
// 解析 CLI 参数 // Logan.Li
flag.Parse()
// 显示版本信息 // Logan.Li
if *flagVersion {
printVersion()
os.Exit(0)
}
// 加载配置 // Logan.Li
cfg := loadConfig()
// 初始化日志
log := logger.New(cfg.LogLevel)
defer log.Sync()
log.Info("AIOps 启动中",
zap.String("version", Version),
zap.String("mode", cfg.Mode),
zap.String("port", cfg.Port),
)
// 设置 Gin 模式(local 与 release 一样走 ReleaseMode)// Logan.Li
switch cfg.Mode {
case "production", "release", "local":
gin.SetMode(gin.ReleaseMode)
default:
gin.SetMode(gin.DebugMode)
}
// 初始化数据库
db, err := database.New(cfg.Database)
if err != nil {
log.Fatal("数据库连接失败", zap.Error(err))
}
// 自动迁移
if err := database.AutoMigrate(db); err != nil {
log.Fatal("数据库迁移失败", zap.Error(err))
}
// 初始化种子数据
seed.SeedData(db)
// 中间件监控默认目标(aiops-redis / aiops-mysql)// Logan.Li
controller.SeedDefaultMiddlewareTargets(db)
// 初始化缓存(Redka 内嵌 / Redis 外部)// Logan.Li
cache, err := ezredis.New(ezredis.Config{
Driver: cfg.Redis.Driver, // 从配置读取 driver
Host: cfg.Redis.Host,
Port: cfg.Redis.Port,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
DataDir: appdir.Root(),
})
if err != nil {
if cfg.Mode == "local" {
log.Fatal("local 模式缓存(redka)初始化失败", zap.Error(err))
}
log.Warn("缓存初始化失败,继续启动", zap.Error(err))
cache = nil
} else {
caps := cache.Capabilities()
log.Info("缓存已就绪",
zap.String("driver", cfg.Redis.Driver),
zap.Bool("sorted_set", caps.SortedSet),
zap.Bool("pubsub", caps.PubSub),
)
if cfg.Mode == "local" && (!caps.SortedSet || !caps.PubSub) {
log.Info("local 模式:Agent 热指标走 DB 直读,流水线日志走轮询")
}
defer cache.Close()
}
// 创建Gin实例
r := gin.New()
// 中间件
r.Use(gin.Recovery())
r.Use(logger.GinLogger(log))
r.Use(CORS())
// 版本信息 API
r.GET("/version", func(c *gin.Context) {
resp := gin.H{
"version": Version,
"build_time": BuildTime,
"git_commit": GitCommit,
"mode": cfg.Mode,
"db_driver": cfg.Database.Driver,
"redis_driver": cfg.Redis.Driver,
}
if cache != nil {
resp["features"] = cache.Capabilities()
}
c.JSON(http.StatusOK, resp)
})
systask.Init(db)
// 注册路由
router.Register(r, db, log, cfg, cache)
// 用户定时任务调度(Linux 5 段 crontab)// Logan.Li
cronSched := cronscheduler.Start(db, func(jobID uint) {
if _, err := controller.RunCronJobByID(db, jobID); err != nil {
log.Warn("定时任务自动执行失败", zap.Uint("job_id", jobID), zap.Error(err))
}
})
defer cronSched.Stop()
// 定期清理旧的 agent 上报数据(保留 7 天,防止表膨胀影响查询性能)// Logan.Li
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
runAgentCleanup := func() {
start := time.Now()
cleanupSQL := database.CleanupAgentReportsSQL(cfg.Database.Driver)
r := db.Exec(cleanupSQL)
msg := fmt.Sprintf("清理 %d 条记录", r.RowsAffected)
status := "success"
if r.Error != nil {
status = "failed"
msg = r.Error.Error()
log.Warn("Agent 上报清理失败", zap.Error(r.Error))
} else if r.RowsAffected > 0 {
log.Info("清理过期的 agent 上报数据", zap.Int64("rows", r.RowsAffected))
}
systask.Record("agent_report_cleanup", status, msg, time.Since(start))
}
time.Sleep(2 * time.Minute)
runAgentCleanup()
for range ticker.C {
runAgentCleanup()
}
}()
// 定期清理超期 SNMP 采集指标(保留 7 天) // Logan.Li
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
runSNMPCleanup := func() {
start := time.Now()
cleanupSQL := database.CleanupSNMPMetricsSQL(cfg.Database.Driver, snmppkg.MetricsRetentionDays)
r := db.Exec(cleanupSQL)
msg := fmt.Sprintf("清理 %d 条 SNMP 指标(保留 %d 天)", r.RowsAffected, snmppkg.MetricsRetentionDays)
status := "success"
if r.Error != nil {
status = "failed"
msg = r.Error.Error()
log.Warn("SNMP 指标清理失败", zap.Error(r.Error))
} else if r.RowsAffected > 0 {
log.Info("清理过期的 SNMP 指标", zap.Int64("rows", r.RowsAffected))
}
systask.Record("snmp_metrics_cleanup", status, msg, time.Since(start))
}
time.Sleep(3 * time.Minute)
runSNMPCleanup()
for range ticker.C {
runSNMPCleanup()
}
}()
// 每日按审计保留策略清理操作/登录/终端审计日志 // Logan.Li
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
runAuditRetentionCleanup := func() {
start := time.Now()
result, err := auditcleaner.RunFromSettings(db)
if err != nil {
systask.Record("audit_log_cleanup", "failed", err.Error(), time.Since(start))
log.Warn("审计日志定时清理失败", zap.Error(err))
return
}
msg := fmt.Sprintf("清理 %d 条(保留 %d 天)", result.Total, result.RetentionDays)
systask.Record("audit_log_cleanup", "success", msg, time.Since(start))
if result.Total > 0 {
log.Info("审计日志定时清理完成",
zap.Int("retention_days", result.RetentionDays),
zap.Int64("total", result.Total),
)
}
now := time.Now()
summary := result.SummaryJSON()
db.Model(&model.AuditSettings{}).Where("id > 0").Updates(map[string]interface{}{
"last_cleanup_at": now,
"last_cleanup_summary": summary,
})
}
time.Sleep(time.Minute)
runAuditRetentionCleanup()
for range ticker.C {
runAuditRetentionCleanup()
}
}()
// 前端静态文件服务(go:embed 模式,二进制部署用) // Logan.Li
setupFrontend(r)
// 创建HTTP服务器
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
}
// 优雅关闭
go func() {
log.Info("服务已启动", zap.String("addr", srv.Addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("服务启动失败", zap.Error(err))
}
}()
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("正在关闭服务...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("强制关闭服务", zap.Error(err))
}
log.Info("服务已退出")
}
// loadConfig 加载配置(CLI flags 优先于 config.yaml)// Logan.Li
func loadConfig() *config.Config {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./configs")
viper.AddConfigPath(".")
viper.AddConfigPath(appdir.Root())
// 如果指定了配置文件路径,使用它 // Logan.Li
if *flagConfig != "" {
viper.SetConfigFile(*flagConfig)
}
// 默认值(独立二进制模式:SQLite + 无 Redis)// Logan.Li
viper.SetDefault("mode", "release")
viper.SetDefault("port", "8080")
viper.SetDefault("log_level", "info")
viper.SetDefault("database.driver", "sqlite")
viper.SetDefault("database.host", "localhost")
viper.SetDefault("database.port", 3306)
viper.SetDefault("database.user", "")
viper.SetDefault("database.password", "")
viper.SetDefault("database.dbname", "sqlite3.db")
viper.SetDefault("database.charset", "utf8mb4")
viper.SetDefault("database.max_conn", 100)
viper.SetDefault("database.max_idle", 10)
viper.SetDefault("redis.host", "localhost")
viper.SetDefault("redis.port", 6379)
viper.SetDefault("redis.driver", "redka")
viper.SetDefault("redis.password", "")
viper.SetDefault("redis.db", 0)
viper.SetDefault("jwt.secret", "") // 生产环境必须通过配置或 JWT_SECRET 环境变量设置
viper.SetDefault("jwt.expire_hour", 24)
viper.SetDefault("websocket.read_buffer_size", 1024)
viper.SetDefault("websocket.write_buffer_size", 1024)
// 读取配置文件(不存在也不报错)// Logan.Li
if err := viper.ReadInConfig(); err != nil {
// 配置文件不存在是正常的(独立二进制模式)
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Fprintf(os.Stderr, "警告: 读取配置文件失败: %v,使用默认配置\n", err)
}
}
// 环境变量覆盖 // Logan.Li
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
// CLI flags 覆盖(最高优先级)// Logan.Li
applyFlags()
// mode=local:强制 SQLite + redka(CLI 显式传 --db-driver/--redis-driver 时可覆盖)// Logan.Li
applyLocalMode()
// 解析配置 // Logan.Li
var cfg config.Config
if err := viper.Unmarshal(&cfg); err != nil {
fmt.Fprintf(os.Stderr, "配置解析失败: %v\n", err)
os.Exit(1)
}
// SQLite / redka 相对路径统一解析到可执行文件目录 // Logan.Li
if cfg.Database.Driver == "sqlite" && cfg.Database.DBName != "" && !filepath.IsAbs(cfg.Database.DBName) {
cfg.Database.DBName = appdir.Resolve(cfg.Database.DBName)
}
return &cfg
}
// isFlagSet 判断 CLI 参数是否被显式传入 // Logan.Li
func isFlagSet(name string) bool {
found := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
// applyLocalMode local 模式:不依赖外部 MySQL/Redis,使用 SQLite + redka // Logan.Li
func applyLocalMode() {
if viper.GetString("mode") != "local" {
return
}
if !isFlagSet("db-driver") {
viper.Set("database.driver", "sqlite")
}
if !isFlagSet("db-name") {
viper.Set("database.dbname", "sqlite3.db")
}
if !isFlagSet("redis-driver") {
viper.Set("redis.driver", "redka")
}
}
// applyFlags 将 CLI 参数映射到 viper(覆盖 config.yaml 和默认值)// Logan.Li
func applyFlags() {
// 检查是否有任何 flag 被显式设置 // Logan.Li
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "port":
viper.Set("port", *flagPort)
case "mode":
viper.Set("mode", *flagMode)
case "log-level":
viper.Set("log_level", *flagLogLevel)
case "db-driver":
viper.Set("database.driver", *flagDBDriver)
case "db-host":
viper.Set("database.host", *flagDBHost)
case "db-port":
viper.Set("database.port", *flagDBPort)
case "db-user":
viper.Set("database.user", *flagDBUser)
case "db-password":
viper.Set("database.password", *flagDBPassword)
case "db-name":
viper.Set("database.dbname", *flagDBName)
case "db-charset":
viper.Set("database.charset", *flagDBCharset)
case "redis-host":
viper.Set("redis.host", *flagRedisHost)
case "redis-port":
viper.Set("redis.port", *flagRedisPort)
case "redis-password":
viper.Set("redis.password", *flagRedisPassword)
case "redis-db":
viper.Set("redis.db", *flagRedisDB)
case "redis-driver":
viper.Set("redis.driver", *flagRedisDriver)
case "jwt-secret":
viper.Set("jwt.secret", *flagJWTSecret)
case "jwt-expire":
viper.Set("jwt.expire_hour", *flagJWTExpire)
}
})
}
// CORS 跨域中间件
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}