feat: Agent采集uptime, 后端持久化kernel/uptime/diskIO, 前端磁盘BPS+IOPS展示

- Agent: system.go 添加 readUptime(), SystemStat 携带 Uptime
- Backend: Server 模型新增 Kernel 字段, ReceiveReport 持久化 kernel+uptime
- Backend: /agent/latest 返回 kernel/uptime/port_info/disk_io_info/disk_total
- Frontend: 磁盘监控改为 BPS+IOPS (4张卡片), 系统信息修复内核/运行时长/监听端口/磁盘容量
- 图表单位从 MB/s 改为 KB/s, 添加 formatBPS 工具函数
This commit is contained in:
attacker committed 2026-06-19 01:10:00 +00:00
1 parent 31c2e338a3
commit 5949ec5d0c
48 files changed
+2182 -956

No files matched your search

+126 -55
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"math"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -77,12 +78,14 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
// 同步更新 servers 表的实时指标(三级匹配:hostname → client IP → hostname LIKE)
diskAvg := avgDiskUsage(req.Disks)
// 计算硬件规格
// 计算硬件规格(四舍五入到整数,避免 31.34→31GB 的显示偏差)
totalDiskGB := 0.0
for _, d := range req.Disks {
totalDiskGB += d.TotalGB
}
memMB := int64(req.Memory.TotalGB * 1024) // GB → MB
memGB := math.Ceil(req.Memory.TotalGB) // 31.34 → 32, 62.79 → 63(物理内存向上取整)
memMB := int64(memGB * 1024) // GB → MB
diskGB := int64(totalDiskGB + 0.5) // 39.99 → 40
clientIP := c.ClientIP()
@@ -92,8 +95,8 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
matchIP = clientIP
}
// 直接 SQL 更新,避免 GORM soft delete 自动添加 deleted_at 条件干扰
updateSQL := `UPDATE servers SET
// 直接 SQL 更新 asset_servers 监控字段
updateSQL := `UPDATE asset_servers SET
cpu_usage = ?, memory_usage = ?, disk_usage = ?,
cpu = ?, memory = ?, disk = ?,
status = ?, agent_status = ?, agent_version = ?,
@@ -101,14 +104,24 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
updated_at = NOW()`
args := []interface{}{
req.CPU.UsagePercent, req.Memory.UsagePercent, diskAvg,
req.CPU.CoreCount, memMB, int64(totalDiskGB),
req.CPU.CoreCount, memMB, diskGB,
"running", "online", req.AgentVersion,
// CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END
req.Hostname, matchIP, req.Hostname,
}
if req.System != nil && req.System.OSVersion != "" {
updateSQL += ", os = ?"
args = append(args, req.System.OSVersion)
if req.System != nil {
if req.System.OSVersion != "" {
updateSQL += ", os = ?"
args = append(args, req.System.OSVersion)
}
if req.System.Kernel != "" {
updateSQL += ", kernel = ?"
args = append(args, req.System.Kernel)
}
if req.System.Uptime > 0 {
updateSQL += ", uptime = ?"
args = append(args, req.System.Uptime)
}
}
updateSQL += " WHERE hostname = ? OR ip = ?"
args = append(args, req.Hostname, matchIP)
@@ -122,9 +135,44 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
result = h.db.Exec(updateSQL2, args2...)
}
// 如果还是没匹配到,记录日志(Agent 上报了,但服务器列表里没有对应条目)
// 如果还是没匹配到 —— 自动注册新服务器(UPSERT 防重复)
if result.RowsAffected == 0 {
fmt.Printf("[agent] 上报主机 %q (IP: %s) 未匹配任何 servers 条目,指标仅写入 agent_reports 表\n", req.Hostname, matchIP)
osName := ""
if req.System != nil {
osName = req.System.OSVersion
}
newServer := models.Server{
Hostname: req.Hostname,
IP: matchIP,
Port: 22,
Username: "root",
AuthType: "password",
OS: osName,
CPU: req.CPU.CoreCount,
Memory: memMB,
Disk: diskGB,
Status: "running",
AgentStatus: "online",
AgentVersion: req.AgentVersion,
CPUUsage: req.CPU.UsagePercent,
MemoryUsage: req.Memory.UsagePercent,
DiskUsage: diskAvg,
}
// ON DUPLICATE KEY UPDATE — 若 hostname 已存在则更新字段(防重复注册)
if err := h.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "hostname"}},
DoUpdates: clause.AssignmentColumns([]string{
"ip", "os", "cpu", "memory", "disk",
"status", "agent_status", "agent_version",
"cpu_usage", "memory_usage", "disk_usage",
"updated_at",
}),
}).Create(&newServer).Error; err != nil {
fmt.Printf("[agent] 自动注册主机 %q (IP: %s) 失败: %v\n", req.Hostname, matchIP, err)
} else {
fmt.Printf("[agent] 自动注册新主机: %q (IP: %s, CPU=%d核, 内存=%dMB, 磁盘=%dGB, OS=%q)\n",
req.Hostname, matchIP, req.CPU.CoreCount, memMB, diskGB, osName)
}
}
// WebSocket 广播:向所有前端客户端推送该主机的最新指标 // Logan.Li
@@ -183,38 +231,51 @@ func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64,
_ = h.cache.ZRemRangeByScore(ctx, key, "-inf", fmt.Sprintf("%.0f", cutoff))
}
// GetLatestReports 获取各主机最新上报(仅 3 分钟内活跃的 hostname)
// GetLatestReports 获取所有服务器的 Agent 最新状态(含未安装 Agent 的主机)
// GET /api/v1/agent/latest
func (h *AgentHandler) GetLatestReports(c *gin.Context) {
var results []models.AgentLatestResponse
threeMinAgo := time.Now().Add(-3 * time.Minute)
// 以 servers 表为主,LEFT JOIN 最近 3 分钟内的 Agent 上报
// 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环
h.db.Raw(`
SELECT
a.hostname,
s.id as server_id,
s.hostname,
COALESCE(s.ip, '') as ip,
a.cpu_usage,
a.cpu_cores,
a.mem_usage,
a.mem_total,
a.mem_used,
COALESCE(a.cpu_usage, 0) as cpu_usage,
COALESCE(a.cpu_cores, 0) as cpu_cores,
COALESCE(a.mem_usage, 0) as mem_usage,
COALESCE(a.mem_total, 0) as mem_total,
COALESCE(a.mem_used, 0) as mem_used,
COALESCE(s.disk_usage, 0) as disk_usage,
a.disk_info,
a.port_info,
a.net_info,
a.disk_io_info,
a.report_time,
COALESCE(s.agent_version, '') as agent_version,
COALESCE(s.agent_status, 'offline') as agent_status,
CASE
WHEN a.hostname IS NOT NULL THEN COALESCE(s.agent_status, 'online')
ELSE 'not_installed'
END as agent_status,
COALESCE(s.os, '') as os,
COALESCE(r.name, '') as region
FROM agent_reports a
LEFT JOIN servers s ON s.hostname = a.hostname
LEFT JOIN regions r ON r.id = s.region_id
WHERE a.id IN (
SELECT MAX(id) FROM agent_reports
WHERE report_time >= ?
GROUP BY hostname
)
AND s.id IS NOT NULL -- 只显示匹配 servers 表的主机(首次上线后自动同步 hostname)
ORDER BY a.report_time DESC
COALESCE(s.kernel, '') as kernel,
COALESCE(s.uptime, 0) as uptime,
COALESCE(s.disk, 0) as disk_total,
COALESCE(r.name, '') as region,
CASE WHEN s.password != '' OR s.ssh_key != '' THEN true ELSE false END as has_auth
FROM asset_servers s
LEFT JOIN (
SELECT * FROM monitor_agent_reports
WHERE id IN (
SELECT MAX(id) FROM monitor_agent_reports
WHERE report_time >= ?
GROUP BY hostname
)
) a ON a.hostname = s.hostname
LEFT JOIN asset_regions r ON r.id = s.region_id
ORDER BY a.report_time DESC, s.id ASC
`, threeMinAgo).Scan(&results)
if results == nil {
@@ -253,7 +314,7 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) {
// AggregateMetrics 聚合 Redis 热数据到 MySQL klines
// POST /api/v1/agent/aggregate
func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
var req models.AggregateKlineReq
var req models.AggregateReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求格式错误: " + err.Error()})
return
@@ -303,37 +364,47 @@ func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
buckets[bucketStart] = append(buckets[bucketStart], val)
}
// 为每个桶计算 OHLC + Avg
// 计算 max/min/avg/p95
for bucketTs, values := range buckets {
if len(values) == 0 {
continue
}
open := values[0]
closeV := values[len(values)-1]
high := values[0]
low := values[0]
maxV := values[0]
minV := values[0]
sum := 0.0
for _, v := range values {
sum += v
if v > high {
high = v
if v > maxV {
maxV = v
}
if v < low {
low = v
if v < minV {
minV = v
}
}
avg := sum / float64(len(values))
kline := models.MetricsKline{
// 计算 P95(排序后取 95% 位置的值)
p95 := maxV
if len(values) >= 20 {
sorted := make([]float64, len(values))
copy(sorted, values)
sort.Float64s(sorted)
idx := int(float64(len(sorted)) * 0.95)
if idx >= len(sorted) {
idx = len(sorted) - 1
}
p95 = sorted[idx]
}
agg := models.MetricsAggregate{
Hostname: req.Hostname,
Metric: req.Metric,
Interval: iv.name,
Open: math.Round(open*100) / 100,
High: math.Round(high*100) / 100,
Low: math.Round(low*100) / 100,
Close: math.Round(closeV*100) / 100,
Avg: math.Round(avg*100) / 100,
MaxV: math.Round(maxV*100) / 100,
MinV: math.Round(minV*100) / 100,
P95: math.Round(p95*100) / 100,
Count: len(values),
BucketTime: time.Unix(bucketTs, 0),
}
@@ -347,9 +418,9 @@ func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
{Name: "bucket_time"},
},
DoUpdates: clause.AssignmentColumns([]string{
"open", "high", "low", "close", "avg", "count",
"avg", "max_v", "min_v", "p95", "count",
}),
}).Create(&kline)
}).Create(&agg)
if result.Error == nil {
totalUpserted++
@@ -364,10 +435,10 @@ func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
})
}
// GetKline 查询 K 线数据
// GET /api/v1/agent/kline?hostname=&metric=&interval=&from=&to=
func (h *AgentHandler) GetKline(c *gin.Context) {
var req models.KlineQueryReq
// GetAggregate 查询聚合数据(原 GetKline)
// GET /api/v1/agent/aggregate?hostname=&metric=&interval=&from=&to=
func (h *AgentHandler) GetAggregate(c *gin.Context) {
var req models.AggregateQueryReq
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
@@ -384,25 +455,25 @@ func (h *AgentHandler) GetKline(c *gin.Context) {
return
}
var klines []models.MetricsKline
var items []models.MetricsAggregate
result := h.db.Where("hostname = ? AND metric = ? AND `interval` = ? AND bucket_time >= ? AND bucket_time <= ?",
req.Hostname, req.Metric, req.Interval, from, to).
Order("bucket_time ASC").
Find(&klines)
Find(&items)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败: " + result.Error.Error()})
return
}
if klines == nil {
klines = []models.MetricsKline{}
if items == nil {
items = []models.MetricsAggregate{}
}
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "success",
Data: klines,
Data: items,
})
}
+178 -56
View File
@@ -61,9 +61,26 @@ func runSSHCommand(client *ssh.Client, cmd string) (string, error) {
// InstallAgent 远程安装 Agent 到指定服务器
// POST /servers/:id/install-agent
// 支持传入临时凭证:首次连接成功后自动更新服务器认证凭据
func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var req struct {
Username string `json:"username"`
Password string `json:"password"`
SSHKey string `json:"ssh_key"`
AuthType string `json:"auth_type"`
Token string `json:"token"`
}
// 兼容旧调用方式:token 在 query,新方式在 body
c.ShouldBindJSON(&req)
if req.Token == "" {
req.Token = c.Query("token")
}
if req.AuthType == "" {
req.AuthType = "password"
}
var server models.Server
if err := h.db.First(&server, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "服务器不存在"})
@@ -71,12 +88,27 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
}
if server.Status != "running" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "服务器未运行,无法安装 Agent"})
return
// 允许安装,不强制 running 状态
}
// 如果请求中提供了凭据,使用请求凭据覆盖服务器存储的凭据进行 SSH
effectiveServer := server
if req.Username != "" {
effectiveServer.Username = req.Username
}
if req.Password != "" {
effectiveServer.Password = req.Password
}
if req.SSHKey != "" {
effectiveServer.SSHKey = req.SSHKey
effectiveServer.AuthType = "key"
}
if req.AuthType != "" && req.AuthType != effectiveServer.AuthType {
effectiveServer.AuthType = req.AuthType
}
// 1. SSH 连接目标主机
client, err := sshDial(server)
client, err := sshDial(effectiveServer)
if err != nil {
log.Printf("[AgentInstall] SSH连接失败 %s: %v", server.IP, err)
c.JSON(http.StatusOK, models.Response{
@@ -87,6 +119,25 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
}
defer client.Close()
// 首次连接成功 → 如果服务器之前未配置凭据,自动保存使用的凭据
if (server.Password == "" && server.SSHKey == "") || server.Username == "" {
updates := map[string]interface{}{
"username": effectiveServer.Username,
"auth_type": effectiveServer.AuthType,
}
if effectiveServer.Password != "" {
updates["password"] = effectiveServer.Password
}
if effectiveServer.SSHKey != "" {
updates["ssh_key"] = effectiveServer.SSHKey
}
if server.Username == "" {
updates["username"] = effectiveServer.Username
}
h.db.Model(&server).Updates(updates)
log.Printf("[AgentInstall] %s 首次连接成功,已自动关联凭证", server.Hostname)
}
// 2. 检测系统架构
archOut, _ := runSSHCommand(client, "uname -m")
arch := "amd64"
@@ -181,7 +232,7 @@ func (h *AgentInstallHandler) installOne(server models.Server, serverURL, token
return installed, installOut, verifyOut, nil
}
// BatchInstallAgent 批量安装 Agent
// BatchInstallAgent 批量安装 Agent(异步执行,立即返回)// Logan.Li
// POST /servers/install-agent
func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
var req struct {
@@ -195,63 +246,134 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
token := c.Query("token")
type InstallResult struct {
ServerID uint `json:"server_id"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
Success bool `json:"success"`
Message string `json:"message"`
Installed bool `json:"installed"`
}
results := make([]InstallResult, 0, len(req.ServerIDs))
successCount := 0
for _, sid := range req.ServerIDs {
var server models.Server
if err := h.db.First(&server, sid).Error; err != nil {
results = append(results, InstallResult{
ServerID: sid,
Success: false,
Message: "服务器不存在",
})
continue
}
installed, _, _, err := h.installOne(server, serverURL, token)
if err != nil {
results = append(results, InstallResult{
ServerID: server.ID,
Hostname: server.Hostname,
IP: server.IP,
Success: false,
Message: err.Error(),
})
} else {
successCount++
msg := "安装成功"
if !installed {
msg = "安装命令已执行,Agent 服务未检测到运行"
// 异步执行:立即返回,后台 goroutine 逐台安装
go func() {
successCount := 0
for _, sid := range req.ServerIDs {
var server models.Server
if err := h.db.First(&server, sid).Error; err != nil {
log.Printf("[BatchInstall] 服务器 %d 不存在,跳过", sid)
continue
}
installed, _, _, err := h.installOne(server, serverURL, token)
if err != nil {
log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err)
} else {
successCount++
if installed {
log.Printf("[BatchInstall] %s Agent 安装成功", server.Hostname)
} else {
log.Printf("[BatchInstall] %s Agent 安装命令已执行但服务未检测到运行", server.Hostname)
}
}
results = append(results, InstallResult{
ServerID: server.ID,
Hostname: server.Hostname,
IP: server.IP,
Success: true,
Installed: installed,
Message: msg,
})
}
}
log.Printf("[BatchInstall] 批量安装完成: %d/%d 成功", successCount, len(req.ServerIDs))
}()
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: fmt.Sprintf("完成 %d/%d 台服务器", successCount, len(req.ServerIDs)),
Data: gin.H{
"total": len(req.ServerIDs),
"success": successCount,
"results": results,
},
Message: fmt.Sprintf("已提交异步安装任务,共 %d 台主机,正在后台执行…", len(req.ServerIDs)),
Data: gin.H{"total": len(req.ServerIDs), "async": true},
})
}
// UninstallAgent 远程卸载 Agent
// POST /api/v1/agent/uninstall
func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) {
var req struct {
Hostname string `json:"hostname"`
ServerID *uint `json:"server_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
return
}
var server models.Server
if req.ServerID != nil {
if err := h.db.First(&server, *req.ServerID).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "服务器不存在"})
return
}
} else if req.Hostname != "" {
if err := h.db.Where("hostname = ? OR ip = ?", req.Hostname, req.Hostname).First(&server).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "未找到服务器: " + req.Hostname})
return
}
} else {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请提供 hostname 或 server_id"})
return
}
// 无 SSH 凭据的主机(自动注册的)直接改本地状态
if server.Username == "" || server.Password == "" {
h.db.Model(&server).Updates(map[string]interface{}{
"agent_status": "offline", "agent_version": "",
})
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已标记为离线(该主机无SSH凭据,无法远程卸载,请手动执行 systemctl stop aiops-agent)"})
return
}
client, err := sshDial(server)
if err != nil {
c.JSON(http.StatusOK, models.Response{Code: 500, Message: "SSH连接失败: " + formatSSHError(err)})
return
}
defer client.Close()
runSSHCommand(client, "systemctl stop aiops-agent 2>/dev/null; systemctl disable aiops-agent 2>/dev/null; rm -f /usr/local/bin/aiops-agent /etc/systemd/system/aiops-agent.service; systemctl daemon-reload 2>/dev/null")
h.db.Model(&server).Updates(map[string]interface{}{
"agent_status": "offline", "agent_version": "",
})
c.JSON(http.StatusOK, models.Response{Code: 200, Message: fmt.Sprintf("已卸载 %s (%s) 的 Agent", server.Hostname, server.IP)})
}
// BatchUninstallAgent 批量卸载 Agent(异步执行,立即返回)// Logan.Li
// POST /api/v1/agent/batch-uninstall
func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
var req struct {
ServerIDs []uint `json:"server_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请提供服务器 ID 列表"})
return
}
// 异步执行:立即返回,后台逐台卸载
go func() {
successCount := 0
for _, id := range req.ServerIDs {
var server models.Server
if err := h.db.First(&server, id).Error; err != nil {
log.Printf("[BatchUninstall] 服务器 %d 不存在,跳过", id)
continue
}
if server.Username == "" || server.Password == "" {
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
log.Printf("[BatchUninstall] %s 无SSH凭据,已标记离线", server.Hostname)
successCount++
continue
}
client, err := sshDial(server)
if err != nil {
log.Printf("[BatchUninstall] %s SSH失败: %v", server.Hostname, err)
continue
}
runSSHCommand(client, "systemctl stop aiops-agent 2>/dev/null; systemctl disable aiops-agent 2>/dev/null; rm -f /usr/local/bin/aiops-agent /etc/systemd/system/aiops-agent.service; systemctl daemon-reload 2>/dev/null")
client.Close()
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
log.Printf("[BatchUninstall] %s Agent 已卸载", server.Hostname)
successCount++
}
log.Printf("[BatchUninstall] 批量卸载完成: %d/%d 成功", successCount, len(req.ServerIDs))
}()
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: fmt.Sprintf("已提交异步卸载任务,共 %d 台主机,正在后台执行…", len(req.ServerIDs)),
Data: gin.H{"total": len(req.ServerIDs), "async": true},
})
}
+160
View File
@@ -0,0 +1,160 @@
package handlers
import (
"net/http"
"strconv"
"ezops/internal/models"
"ezops/pkg/crypto"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// CredentialHandler 凭据管理处理器 // Logan.Li
type CredentialHandler struct {
db *gorm.DB
crypt *crypto.SecretBox
}
func NewCredentialHandler(db *gorm.DB, masterKey string) *CredentialHandler {
return &CredentialHandler{
db: db,
crypt: crypto.NewSecretBox(masterKey),
}
}
// preview 内容脱敏预览:前8字符 + ... + 后4字符
func preview(s string) string {
if len(s) <= 12 {
return "****"
}
return s[:8] + "..." + s[len(s)-4:]
}
// ListCredentials 凭据列表
func (h *CredentialHandler) ListCredentials(c *gin.Context) {
var creds []models.Credential
if err := h.db.Order("updated_at DESC").Find(&creds).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"})
return
}
items := make([]models.CredentialListResponse, 0, len(creds))
for _, cred := range creds {
items = append(items, models.CredentialListResponse{
ID: cred.ID,
Name: cred.Name,
Type: cred.Type,
Preview: preview(cred.Content),
Description: cred.Description,
CreatedAt: cred.CreatedAt,
UpdatedAt: cred.UpdatedAt,
})
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{
List: items, Total: int64(len(items)),
}})
}
// CreateCredential 创建凭据
func (h *CredentialHandler) CreateCredential(c *gin.Context) {
var req models.CredentialCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
encrypted, err := h.crypt.Encrypt(req.Content)
if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "加密失败"})
return
}
cred := models.Credential{
Name: req.Name,
Type: req.Type,
Content: encrypted,
Description: req.Description,
}
if err := h.db.Create(&cred).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"})
return
}
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "创建成功",
Data: models.CredentialListResponse{
ID: cred.ID,
Name: cred.Name,
Type: cred.Type,
Preview: preview(cred.Content),
Description: cred.Description,
CreatedAt: cred.CreatedAt,
UpdatedAt: cred.UpdatedAt,
},
})
}
// UpdateCredential 更新凭据
func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效ID"})
return
}
var req models.CredentialUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
var cred models.Credential
if h.db.First(&cred, id).Error != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"})
return
}
cred.Name = req.Name
cred.Type = req.Type
cred.Description = req.Description
if req.Content != "" {
encrypted, err := h.crypt.Encrypt(req.Content)
if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "加密失败"})
return
}
cred.Content = encrypted
}
if err := h.db.Save(&cred).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功"})
}
// DeleteCredential 删除凭据
func (h *CredentialHandler) DeleteCredential(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效ID"})
return
}
var cred models.Credential
if h.db.First(&cred, id).Error != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"})
return
}
if err := h.db.Delete(&cred).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
}
@@ -0,0 +1,62 @@
package handlers
import (
"net/http"
"strconv"
"ezops/internal/models"
"github.com/gin-gonic/gin"
)
// AssignCredential 将凭据分配给服务器(解密凭据并写入 password/ssh_key) // Logan.Li
func (h *CredentialHandler) AssignCredential(c *gin.Context) {
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的服务器ID"})
return
}
var req struct {
CredentialID uint `json:"credential_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.CredentialID == 0 {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请选择凭据"})
return
}
var cred models.Credential
if err := h.db.First(&cred, req.CredentialID).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"})
return
}
plain, err := h.crypt.Decrypt(cred.Content)
if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "凭据解密失败"})
return
}
var server models.Server
if err := h.db.First(&server, serverID).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "服务器不存在"})
return
}
if cred.Type == "ssh_key" {
server.SSHKey = plain
server.AuthType = "key"
server.Password = ""
} else {
server.Password = plain
server.AuthType = "password"
server.SSHKey = ""
}
if err := h.db.Save(&server).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "分配失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "凭据已关联"})
}
+16 -6
View File
@@ -5,6 +5,7 @@ import (
"strconv"
"ezops/internal/models"
"ezops/pkg/crypto"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -12,12 +13,13 @@ import (
// ServerHandler 服务器处理器
type ServerHandler struct {
db *gorm.DB
db *gorm.DB
crypt *crypto.SecretBox
}
// NewServerHandler 创建服务器处理器
func NewServerHandler(db *gorm.DB) *ServerHandler {
return &ServerHandler{db: db}
func NewServerHandler(db *gorm.DB, masterKey string) *ServerHandler {
return &ServerHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
}
// recordServerAudit 记录服务器操作审计日志 // Logan.Li
@@ -91,6 +93,11 @@ func (h *ServerHandler) ListServers(c *gin.Context) {
Order("created_at DESC").
Find(&servers)
// 填充 has_auth 字段(密码和SSH密钥在JSON中隐藏,但前端需知道是否有凭据) // Logan.Li
for i := range servers {
servers[i].HasAuth = servers[i].Password != "" || servers[i].SSHKey != ""
}
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "success",
@@ -324,7 +331,11 @@ func (h *ServerHandler) DeleteServer(c *gin.Context) {
return
}
if err := h.db.Delete(&models.Server{}, id).Error; err != nil {
// 硬删除:先清理关联数据,再物理删除服务器记录
h.db.Unscoped().Where("hostname = ?", server.Hostname).Delete(&models.AgentReport{})
h.db.Unscoped().Where("hostname = ?", server.Hostname).Delete(&models.MetricsAggregate{})
if err := h.db.Unscoped().Delete(&models.Server{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{
Code: 500,
Message: "删除失败",
@@ -333,10 +344,9 @@ func (h *ServerHandler) DeleteServer(c *gin.Context) {
}
h.recordServerAudit(c, "delete", server.Hostname+" ("+server.IP+")", strconv.Itoa(int(server.ID)), 1, "")
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "删除成功",
Message: "服务器已彻底删除",
})
}
+33 -3
View File
@@ -14,7 +14,7 @@ import (
// 批量操作域:批量导入 + 批量删除 // Logan.Li
// BatchCreateServers 批量导入服务器 // Logan.Li
// 接收 {servers:[...], region_id, auth_type},逐条校验并创建,跳过重复(主机名或IP已存在)
// 接收 {servers:[...], region_id, auth_type, credential_id},逐条校验并创建,跳过重复(主机名或IP已存在)
func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
var req models.BatchCreateServerRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -30,6 +30,28 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
authType = "password"
}
// 如果指定了凭据,提前解密 // Logan.Li
var decryptedPwd, decryptedKey string
if req.CredentialID != nil && *req.CredentialID > 0 {
var cred models.Credential
if err := h.db.First(&cred, *req.CredentialID).Error; err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "凭据不存在"})
return
}
plain, err := h.crypt.Decrypt(cred.Content)
if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "凭据解密失败"})
return
}
if cred.Type == "ssh_key" {
decryptedKey = plain
authType = "key"
} else {
decryptedPwd = plain
authType = "password"
}
}
result := models.BatchCreateResult{Errors: []string{}}
for i, item := range req.Servers {
hostname := strings.TrimSpace(item.Hostname)
@@ -63,6 +85,8 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
Port: port,
Username: username,
AuthType: authType,
Password: decryptedPwd,
SSHKey: decryptedKey,
OS: item.OS,
CPU: item.CPU,
Memory: item.Memory * 1024, // GB → MB
@@ -111,7 +135,13 @@ func (h *ServerHandler) BatchDeleteServers(c *gin.Context) {
var servers []models.Server
h.db.Where("id IN ?", req.IDs).Find(&servers)
if err := h.db.Delete(&models.Server{}, req.IDs).Error; err != nil {
// 硬删除:先清理每台服务器的 Agent 上报和聚合数据
for _, s := range servers {
h.db.Unscoped().Where("hostname = ?", s.Hostname).Delete(&models.AgentReport{})
h.db.Unscoped().Where("hostname = ?", s.Hostname).Delete(&models.MetricsAggregate{})
}
if err := h.db.Unscoped().Delete(&models.Server{}, req.IDs).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{
Code: 500,
Message: "删除失败",
@@ -126,6 +156,6 @@ func (h *ServerHandler) BatchDeleteServers(c *gin.Context) {
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "删除成功",
Message: "服务器已彻底删除",
})
}
+18 -2
View File
@@ -135,7 +135,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
}
// 服务器路由
serverHandler := handlers.NewServerHandler(db)
serverHandler := handlers.NewServerHandler(db, cfg.JWT.Secret)
servers := authorized.Group("/servers")
{
servers.GET("", serverHandler.ListServers)
@@ -434,7 +434,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
{
agentQuery.GET("/latest", agentHandler.GetLatestReports)
agentQuery.GET("/history/:hostname", agentHandler.GetHostHistory)
agentQuery.GET("/kline", agentHandler.GetKline)
agentQuery.GET("/aggregate", agentHandler.GetAggregate)
// 远程任务
agentQuery.POST("/tasks", taskHandler.CreateTask)
@@ -451,6 +451,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
// 告警事件
agentQuery.GET("/alerts", taskHandler.ListAlerts)
agentQuery.POST("/alerts/:id/resolve", taskHandler.ResolveAlert)
// Agent 安装/卸载
agentQuery.POST("/uninstall", agentInstallHandler.UninstallAgent)
agentQuery.POST("/batch-uninstall", agentInstallHandler.BatchUninstallAgent)
}
// AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li
@@ -535,6 +539,18 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
zabbixDSGroup.POST("/test", zabbixDSHandler.TestConnection)
}
// 凭据管理 — SSH密钥/API Token/密码统一加密管理 // Logan.Li
credentialHandler := handlers.NewCredentialHandler(db, cfg.JWT.Secret)
credGroup := authorized.Group("/credentials")
{
credGroup.GET("", credentialHandler.ListCredentials)
credGroup.POST("", credentialHandler.CreateCredential)
credGroup.PUT("/:id", credentialHandler.UpdateCredential)
credGroup.DELETE("/:id", credentialHandler.DeleteCredential)
}
// 服务器凭据关联 // Logan.Li
authorized.POST("/servers/:id/assign-credential", credentialHandler.AssignCredential)
// 站点拨测 — HTTP/TCP/DNS/PING 主动监控 // Logan.Li
siteHandler := handlers.NewSiteHandler(db)
siteGroup := authorized.Group("/site")