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:
1 parent
31c2e338a3
commit
5949ec5d0c
48 files changed
+2104
-878
No files matched your search
@@ -13,7 +13,8 @@ import (
|
|||||||
// SystemInfo 包含系统静态信息(Agent 版本通过编译时注入,非运行时采集)
|
// SystemInfo 包含系统静态信息(Agent 版本通过编译时注入,非运行时采集)
|
||||||
type SystemInfo struct {
|
type SystemInfo struct {
|
||||||
OSVersion string // OS 发行版版本 (如 "Ubuntu 22.04.4 LTS")
|
OSVersion string // OS 发行版版本 (如 "Ubuntu 22.04.4 LTS")
|
||||||
Kernel string // 内核版本 (如 "Linux 6.8.0-90-generic")
|
Kernel string // 内核版本 (如 "6.8.0-90-generic")
|
||||||
|
UptimeSeconds int64 // 系统运行时长(秒)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CollectSystem 采集系统静态信息。
|
// CollectSystem 采集系统静态信息。
|
||||||
@@ -28,6 +29,9 @@ func CollectSystem() SystemInfo {
|
|||||||
// 读取 /proc/version 获取内核版本
|
// 读取 /proc/version 获取内核版本
|
||||||
info.Kernel = readProcVersion()
|
info.Kernel = readProcVersion()
|
||||||
|
|
||||||
|
// 读取 /proc/uptime 获取运行时长
|
||||||
|
info.UptimeSeconds = readUptime()
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,3 +71,29 @@ func readProcVersion() string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readUptime 从 /proc/uptime 读取系统运行秒数
|
||||||
|
func readUptime() int64 {
|
||||||
|
data, err := os.ReadFile("/proc/uptime")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// 格式: "12345.67 98765.43"
|
||||||
|
// 第一个值是系统总运行秒数(含小数)
|
||||||
|
fields := strings.Fields(strings.TrimSpace(string(data)))
|
||||||
|
if len(fields) < 1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// 截断小数部分,只取整数
|
||||||
|
parts := strings.SplitN(fields[0], ".", 2)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
var sec int64
|
||||||
|
for _, c := range parts[0] {
|
||||||
|
if c >= '0' && c <= '9' {
|
||||||
|
sec = sec*10 + int64(c-'0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sec
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -105,6 +105,7 @@ func main() {
|
|||||||
report.System = &model.SystemStat{
|
report.System = &model.SystemStat{
|
||||||
OSVersion: sysInfo.OSVersion,
|
OSVersion: sysInfo.OSVersion,
|
||||||
Kernel: sysInfo.Kernel,
|
Kernel: sysInfo.Kernel,
|
||||||
|
Uptime: sysInfo.UptimeSeconds,
|
||||||
}
|
}
|
||||||
sysReportCount++
|
sysReportCount++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -77,12 +78,14 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
|||||||
// 同步更新 servers 表的实时指标(三级匹配:hostname → client IP → hostname LIKE)
|
// 同步更新 servers 表的实时指标(三级匹配:hostname → client IP → hostname LIKE)
|
||||||
diskAvg := avgDiskUsage(req.Disks)
|
diskAvg := avgDiskUsage(req.Disks)
|
||||||
|
|
||||||
// 计算硬件规格
|
// 计算硬件规格(四舍五入到整数,避免 31.34→31GB 的显示偏差)
|
||||||
totalDiskGB := 0.0
|
totalDiskGB := 0.0
|
||||||
for _, d := range req.Disks {
|
for _, d := range req.Disks {
|
||||||
totalDiskGB += d.TotalGB
|
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()
|
clientIP := c.ClientIP()
|
||||||
|
|
||||||
@@ -92,8 +95,8 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
|||||||
matchIP = clientIP
|
matchIP = clientIP
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接 SQL 更新,避免 GORM soft delete 自动添加 deleted_at 条件干扰
|
// 直接 SQL 更新 asset_servers 监控字段
|
||||||
updateSQL := `UPDATE servers SET
|
updateSQL := `UPDATE asset_servers SET
|
||||||
cpu_usage = ?, memory_usage = ?, disk_usage = ?,
|
cpu_usage = ?, memory_usage = ?, disk_usage = ?,
|
||||||
cpu = ?, memory = ?, disk = ?,
|
cpu = ?, memory = ?, disk = ?,
|
||||||
status = ?, agent_status = ?, agent_version = ?,
|
status = ?, agent_status = ?, agent_version = ?,
|
||||||
@@ -101,15 +104,25 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
|||||||
updated_at = NOW()`
|
updated_at = NOW()`
|
||||||
args := []interface{}{
|
args := []interface{}{
|
||||||
req.CPU.UsagePercent, req.Memory.UsagePercent, diskAvg,
|
req.CPU.UsagePercent, req.Memory.UsagePercent, diskAvg,
|
||||||
req.CPU.CoreCount, memMB, int64(totalDiskGB),
|
req.CPU.CoreCount, memMB, diskGB,
|
||||||
"running", "online", req.AgentVersion,
|
"running", "online", req.AgentVersion,
|
||||||
// CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END
|
// CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END
|
||||||
req.Hostname, matchIP, req.Hostname,
|
req.Hostname, matchIP, req.Hostname,
|
||||||
}
|
}
|
||||||
if req.System != nil && req.System.OSVersion != "" {
|
if req.System != nil {
|
||||||
|
if req.System.OSVersion != "" {
|
||||||
updateSQL += ", os = ?"
|
updateSQL += ", os = ?"
|
||||||
args = append(args, req.System.OSVersion)
|
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 = ?"
|
updateSQL += " WHERE hostname = ? OR ip = ?"
|
||||||
args = append(args, req.Hostname, matchIP)
|
args = append(args, req.Hostname, matchIP)
|
||||||
|
|
||||||
@@ -122,9 +135,44 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
|||||||
result = h.db.Exec(updateSQL2, args2...)
|
result = h.db.Exec(updateSQL2, args2...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果还是没匹配到,记录日志(Agent 上报了,但服务器列表里没有对应条目)
|
// 如果还是没匹配到 —— 自动注册新服务器(UPSERT 防重复)
|
||||||
if result.RowsAffected == 0 {
|
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
|
// 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))
|
_ = h.cache.ZRemRangeByScore(ctx, key, "-inf", fmt.Sprintf("%.0f", cutoff))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLatestReports 获取各主机最新上报(仅 3 分钟内活跃的 hostname)
|
// GetLatestReports 获取所有服务器的 Agent 最新状态(含未安装 Agent 的主机)
|
||||||
// GET /api/v1/agent/latest
|
// GET /api/v1/agent/latest
|
||||||
func (h *AgentHandler) GetLatestReports(c *gin.Context) {
|
func (h *AgentHandler) GetLatestReports(c *gin.Context) {
|
||||||
var results []models.AgentLatestResponse
|
var results []models.AgentLatestResponse
|
||||||
threeMinAgo := time.Now().Add(-3 * time.Minute)
|
threeMinAgo := time.Now().Add(-3 * time.Minute)
|
||||||
|
// 以 servers 表为主,LEFT JOIN 最近 3 分钟内的 Agent 上报
|
||||||
|
// 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环
|
||||||
h.db.Raw(`
|
h.db.Raw(`
|
||||||
SELECT
|
SELECT
|
||||||
a.hostname,
|
s.id as server_id,
|
||||||
|
s.hostname,
|
||||||
COALESCE(s.ip, '') as ip,
|
COALESCE(s.ip, '') as ip,
|
||||||
a.cpu_usage,
|
COALESCE(a.cpu_usage, 0) as cpu_usage,
|
||||||
a.cpu_cores,
|
COALESCE(a.cpu_cores, 0) as cpu_cores,
|
||||||
a.mem_usage,
|
COALESCE(a.mem_usage, 0) as mem_usage,
|
||||||
a.mem_total,
|
COALESCE(a.mem_total, 0) as mem_total,
|
||||||
a.mem_used,
|
COALESCE(a.mem_used, 0) as mem_used,
|
||||||
COALESCE(s.disk_usage, 0) as disk_usage,
|
COALESCE(s.disk_usage, 0) as disk_usage,
|
||||||
a.disk_info,
|
a.disk_info,
|
||||||
|
a.port_info,
|
||||||
a.net_info,
|
a.net_info,
|
||||||
|
a.disk_io_info,
|
||||||
a.report_time,
|
a.report_time,
|
||||||
COALESCE(s.agent_version, '') as agent_version,
|
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(s.os, '') as os,
|
||||||
COALESCE(r.name, '') as region
|
COALESCE(s.kernel, '') as kernel,
|
||||||
FROM agent_reports a
|
COALESCE(s.uptime, 0) as uptime,
|
||||||
LEFT JOIN servers s ON s.hostname = a.hostname
|
COALESCE(s.disk, 0) as disk_total,
|
||||||
LEFT JOIN regions r ON r.id = s.region_id
|
COALESCE(r.name, '') as region,
|
||||||
WHERE a.id IN (
|
CASE WHEN s.password != '' OR s.ssh_key != '' THEN true ELSE false END as has_auth
|
||||||
SELECT MAX(id) FROM agent_reports
|
FROM asset_servers s
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT * FROM monitor_agent_reports
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT MAX(id) FROM monitor_agent_reports
|
||||||
WHERE report_time >= ?
|
WHERE report_time >= ?
|
||||||
GROUP BY hostname
|
GROUP BY hostname
|
||||||
)
|
)
|
||||||
AND s.id IS NOT NULL -- 只显示匹配 servers 表的主机(首次上线后自动同步 hostname)
|
) a ON a.hostname = s.hostname
|
||||||
ORDER BY a.report_time DESC
|
LEFT JOIN asset_regions r ON r.id = s.region_id
|
||||||
|
ORDER BY a.report_time DESC, s.id ASC
|
||||||
`, threeMinAgo).Scan(&results)
|
`, threeMinAgo).Scan(&results)
|
||||||
|
|
||||||
if results == nil {
|
if results == nil {
|
||||||
@@ -253,7 +314,7 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) {
|
|||||||
// AggregateMetrics 聚合 Redis 热数据到 MySQL klines
|
// AggregateMetrics 聚合 Redis 热数据到 MySQL klines
|
||||||
// POST /api/v1/agent/aggregate
|
// POST /api/v1/agent/aggregate
|
||||||
func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
|
func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
|
||||||
var req models.AggregateKlineReq
|
var req models.AggregateReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求格式错误: " + err.Error()})
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求格式错误: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -303,37 +364,47 @@ func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
|
|||||||
buckets[bucketStart] = append(buckets[bucketStart], val)
|
buckets[bucketStart] = append(buckets[bucketStart], val)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为每个桶计算 OHLC + Avg
|
// 计算 max/min/avg/p95
|
||||||
for bucketTs, values := range buckets {
|
for bucketTs, values := range buckets {
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
open := values[0]
|
maxV := values[0]
|
||||||
closeV := values[len(values)-1]
|
minV := values[0]
|
||||||
high := values[0]
|
|
||||||
low := values[0]
|
|
||||||
sum := 0.0
|
sum := 0.0
|
||||||
|
|
||||||
for _, v := range values {
|
for _, v := range values {
|
||||||
sum += v
|
sum += v
|
||||||
if v > high {
|
if v > maxV {
|
||||||
high = v
|
maxV = v
|
||||||
}
|
}
|
||||||
if v < low {
|
if v < minV {
|
||||||
low = v
|
minV = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
avg := sum / float64(len(values))
|
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,
|
Hostname: req.Hostname,
|
||||||
Metric: req.Metric,
|
Metric: req.Metric,
|
||||||
Interval: iv.name,
|
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,
|
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),
|
Count: len(values),
|
||||||
BucketTime: time.Unix(bucketTs, 0),
|
BucketTime: time.Unix(bucketTs, 0),
|
||||||
}
|
}
|
||||||
@@ -347,9 +418,9 @@ func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
|
|||||||
{Name: "bucket_time"},
|
{Name: "bucket_time"},
|
||||||
},
|
},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
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 {
|
if result.Error == nil {
|
||||||
totalUpserted++
|
totalUpserted++
|
||||||
@@ -364,10 +435,10 @@ func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetKline 查询 K 线数据
|
// GetAggregate 查询聚合数据(原 GetKline)
|
||||||
// GET /api/v1/agent/kline?hostname=&metric=&interval=&from=&to=
|
// GET /api/v1/agent/aggregate?hostname=&metric=&interval=&from=&to=
|
||||||
func (h *AgentHandler) GetKline(c *gin.Context) {
|
func (h *AgentHandler) GetAggregate(c *gin.Context) {
|
||||||
var req models.KlineQueryReq
|
var req models.AggregateQueryReq
|
||||||
if err := c.ShouldBindQuery(&req); err != nil {
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -384,25 +455,25 @@ func (h *AgentHandler) GetKline(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var klines []models.MetricsKline
|
var items []models.MetricsAggregate
|
||||||
result := h.db.Where("hostname = ? AND metric = ? AND `interval` = ? AND bucket_time >= ? AND bucket_time <= ?",
|
result := h.db.Where("hostname = ? AND metric = ? AND `interval` = ? AND bucket_time >= ? AND bucket_time <= ?",
|
||||||
req.Hostname, req.Metric, req.Interval, from, to).
|
req.Hostname, req.Metric, req.Interval, from, to).
|
||||||
Order("bucket_time ASC").
|
Order("bucket_time ASC").
|
||||||
Find(&klines)
|
Find(&items)
|
||||||
|
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败: " + result.Error.Error()})
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败: " + result.Error.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if klines == nil {
|
if items == nil {
|
||||||
klines = []models.MetricsKline{}
|
items = []models.MetricsAggregate{}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.Response{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "success",
|
Message: "success",
|
||||||
Data: klines,
|
Data: items,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,26 @@ func runSSHCommand(client *ssh.Client, cmd string) (string, error) {
|
|||||||
|
|
||||||
// InstallAgent 远程安装 Agent 到指定服务器
|
// InstallAgent 远程安装 Agent 到指定服务器
|
||||||
// POST /servers/:id/install-agent
|
// POST /servers/:id/install-agent
|
||||||
|
// 支持传入临时凭证:首次连接成功后自动更新服务器认证凭据
|
||||||
func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||||
id, _ := strconv.Atoi(c.Param("id"))
|
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
|
var server models.Server
|
||||||
if err := h.db.First(&server, id).Error; err != nil {
|
if err := h.db.First(&server, id).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "服务器不存在"})
|
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "服务器不存在"})
|
||||||
@@ -71,12 +88,27 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if server.Status != "running" {
|
if server.Status != "running" {
|
||||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "服务器未运行,无法安装 Agent"})
|
// 允许安装,不强制 running 状态
|
||||||
return
|
}
|
||||||
|
|
||||||
|
// 如果请求中提供了凭据,使用请求凭据覆盖服务器存储的凭据进行 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 连接目标主机
|
// 1. SSH 连接目标主机
|
||||||
client, err := sshDial(server)
|
client, err := sshDial(effectiveServer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[AgentInstall] SSH连接失败 %s: %v", server.IP, err)
|
log.Printf("[AgentInstall] SSH连接失败 %s: %v", server.IP, err)
|
||||||
c.JSON(http.StatusOK, models.Response{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
@@ -87,6 +119,25 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
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. 检测系统架构
|
// 2. 检测系统架构
|
||||||
archOut, _ := runSSHCommand(client, "uname -m")
|
archOut, _ := runSSHCommand(client, "uname -m")
|
||||||
arch := "amd64"
|
arch := "amd64"
|
||||||
@@ -181,7 +232,7 @@ func (h *AgentInstallHandler) installOne(server models.Server, serverURL, token
|
|||||||
return installed, installOut, verifyOut, nil
|
return installed, installOut, verifyOut, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchInstallAgent 批量安装 Agent
|
// BatchInstallAgent 批量安装 Agent(异步执行,立即返回)// Logan.Li
|
||||||
// POST /servers/install-agent
|
// POST /servers/install-agent
|
||||||
func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -195,63 +246,134 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
|||||||
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
|
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
|
||||||
token := c.Query("token")
|
token := c.Query("token")
|
||||||
|
|
||||||
type InstallResult struct {
|
// 异步执行:立即返回,后台 goroutine 逐台安装
|
||||||
ServerID uint `json:"server_id"`
|
go func() {
|
||||||
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
|
successCount := 0
|
||||||
|
|
||||||
for _, sid := range req.ServerIDs {
|
for _, sid := range req.ServerIDs {
|
||||||
var server models.Server
|
var server models.Server
|
||||||
if err := h.db.First(&server, sid).Error; err != nil {
|
if err := h.db.First(&server, sid).Error; err != nil {
|
||||||
results = append(results, InstallResult{
|
log.Printf("[BatchInstall] 服务器 %d 不存在,跳过", sid)
|
||||||
ServerID: sid,
|
|
||||||
Success: false,
|
|
||||||
Message: "服务器不存在",
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
installed, _, _, err := h.installOne(server, serverURL, token)
|
installed, _, _, err := h.installOne(server, serverURL, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, InstallResult{
|
log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err)
|
||||||
ServerID: server.ID,
|
|
||||||
Hostname: server.Hostname,
|
|
||||||
IP: server.IP,
|
|
||||||
Success: false,
|
|
||||||
Message: err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
successCount++
|
successCount++
|
||||||
msg := "安装成功"
|
if installed {
|
||||||
if !installed {
|
log.Printf("[BatchInstall] %s Agent 安装成功", server.Hostname)
|
||||||
msg = "安装命令已执行,Agent 服务未检测到运行"
|
} 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{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: fmt.Sprintf("完成 %d/%d 台服务器", successCount, len(req.ServerIDs)),
|
Message: fmt.Sprintf("已提交异步安装任务,共 %d 台主机,正在后台执行…", len(req.ServerIDs)),
|
||||||
Data: gin.H{
|
Data: gin.H{"total": len(req.ServerIDs), "async": true},
|
||||||
"total": len(req.ServerIDs),
|
})
|
||||||
"success": successCount,
|
}
|
||||||
"results": results,
|
|
||||||
},
|
// 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},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: "凭据已关联"})
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"ezops/internal/models"
|
"ezops/internal/models"
|
||||||
|
"ezops/pkg/crypto"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -13,11 +14,12 @@ import (
|
|||||||
// ServerHandler 服务器处理器
|
// ServerHandler 服务器处理器
|
||||||
type ServerHandler struct {
|
type ServerHandler struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
crypt *crypto.SecretBox
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServerHandler 创建服务器处理器
|
// NewServerHandler 创建服务器处理器
|
||||||
func NewServerHandler(db *gorm.DB) *ServerHandler {
|
func NewServerHandler(db *gorm.DB, masterKey string) *ServerHandler {
|
||||||
return &ServerHandler{db: db}
|
return &ServerHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordServerAudit 记录服务器操作审计日志 // Logan.Li
|
// recordServerAudit 记录服务器操作审计日志 // Logan.Li
|
||||||
@@ -91,6 +93,11 @@ func (h *ServerHandler) ListServers(c *gin.Context) {
|
|||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Find(&servers)
|
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{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "success",
|
Message: "success",
|
||||||
@@ -324,7 +331,11 @@ func (h *ServerHandler) DeleteServer(c *gin.Context) {
|
|||||||
return
|
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{
|
c.JSON(http.StatusInternalServerError, models.Response{
|
||||||
Code: 500,
|
Code: 500,
|
||||||
Message: "删除失败",
|
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, "")
|
h.recordServerAudit(c, "delete", server.Hostname+" ("+server.IP+")", strconv.Itoa(int(server.ID)), 1, "")
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.Response{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "删除成功",
|
Message: "服务器已彻底删除",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// 批量操作域:批量导入 + 批量删除 // Logan.Li
|
// 批量操作域:批量导入 + 批量删除 // Logan.Li
|
||||||
|
|
||||||
// BatchCreateServers 批量导入服务器 // 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) {
|
func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
||||||
var req models.BatchCreateServerRequest
|
var req models.BatchCreateServerRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -30,6 +30,28 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
|||||||
authType = "password"
|
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{}}
|
result := models.BatchCreateResult{Errors: []string{}}
|
||||||
for i, item := range req.Servers {
|
for i, item := range req.Servers {
|
||||||
hostname := strings.TrimSpace(item.Hostname)
|
hostname := strings.TrimSpace(item.Hostname)
|
||||||
@@ -63,6 +85,8 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
|||||||
Port: port,
|
Port: port,
|
||||||
Username: username,
|
Username: username,
|
||||||
AuthType: authType,
|
AuthType: authType,
|
||||||
|
Password: decryptedPwd,
|
||||||
|
SSHKey: decryptedKey,
|
||||||
OS: item.OS,
|
OS: item.OS,
|
||||||
CPU: item.CPU,
|
CPU: item.CPU,
|
||||||
Memory: item.Memory * 1024, // GB → MB
|
Memory: item.Memory * 1024, // GB → MB
|
||||||
@@ -111,7 +135,13 @@ func (h *ServerHandler) BatchDeleteServers(c *gin.Context) {
|
|||||||
var servers []models.Server
|
var servers []models.Server
|
||||||
h.db.Where("id IN ?", req.IDs).Find(&servers)
|
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{
|
c.JSON(http.StatusInternalServerError, models.Response{
|
||||||
Code: 500,
|
Code: 500,
|
||||||
Message: "删除失败",
|
Message: "删除失败",
|
||||||
@@ -126,6 +156,6 @@ func (h *ServerHandler) BatchDeleteServers(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, models.Response{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "删除成功",
|
Message: "服务器已彻底删除",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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 := authorized.Group("/servers")
|
||||||
{
|
{
|
||||||
servers.GET("", serverHandler.ListServers)
|
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("/latest", agentHandler.GetLatestReports)
|
||||||
agentQuery.GET("/history/:hostname", agentHandler.GetHostHistory)
|
agentQuery.GET("/history/:hostname", agentHandler.GetHostHistory)
|
||||||
agentQuery.GET("/kline", agentHandler.GetKline)
|
agentQuery.GET("/aggregate", agentHandler.GetAggregate)
|
||||||
|
|
||||||
// 远程任务
|
// 远程任务
|
||||||
agentQuery.POST("/tasks", taskHandler.CreateTask)
|
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.GET("/alerts", taskHandler.ListAlerts)
|
||||||
agentQuery.POST("/alerts/:id/resolve", taskHandler.ResolveAlert)
|
agentQuery.POST("/alerts/:id/resolve", taskHandler.ResolveAlert)
|
||||||
|
|
||||||
|
// Agent 安装/卸载
|
||||||
|
agentQuery.POST("/uninstall", agentInstallHandler.UninstallAgent)
|
||||||
|
agentQuery.POST("/batch-uninstall", agentInstallHandler.BatchUninstallAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li
|
// 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)
|
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
|
// 站点拨测 — HTTP/TCP/DNS/PING 主动监控 // Logan.Li
|
||||||
siteHandler := handlers.NewSiteHandler(db)
|
siteHandler := handlers.NewSiteHandler(db)
|
||||||
siteGroup := authorized.Group("/site")
|
siteGroup := authorized.Group("/site")
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentReport Agent 上报的原始指标数据 // Logan.Li
|
// AgentReport Agent 上报的原始指标数据 // Logan.Li
|
||||||
@@ -25,15 +24,15 @@ type AgentReport struct {
|
|||||||
ReportTime time.Time `json:"report_time"` // 上报时间
|
ReportTime time.Time `json:"report_time"` // 上报时间
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AgentReport) TableName() string {
|
func (AgentReport) TableName() string {
|
||||||
return "agent_reports"
|
return "monitor_agent_reports"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentLatestResponse Agent 最新状态(含服务器元信息)// Logan.Li
|
// AgentLatestResponse Agent 最新状态(含服务器元信息)// Logan.Li
|
||||||
type AgentLatestResponse struct {
|
type AgentLatestResponse struct {
|
||||||
|
ServerID uint `json:"server_id"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
CPUUsage float64 `json:"cpu_usage"`
|
CPUUsage float64 `json:"cpu_usage"`
|
||||||
@@ -42,13 +41,19 @@ type AgentLatestResponse struct {
|
|||||||
MemTotal float64 `json:"mem_total"`
|
MemTotal float64 `json:"mem_total"`
|
||||||
MemUsed float64 `json:"mem_used"`
|
MemUsed float64 `json:"mem_used"`
|
||||||
DiskUsage float64 `json:"disk_usage"`
|
DiskUsage float64 `json:"disk_usage"`
|
||||||
|
DiskTotal int64 `json:"disk_total"` // 磁盘总容量 GB
|
||||||
DiskInfo string `json:"disk_info"`
|
DiskInfo string `json:"disk_info"`
|
||||||
|
PortInfo string `json:"port_info"`
|
||||||
NetInfo string `json:"net_info"`
|
NetInfo string `json:"net_info"`
|
||||||
|
DiskIOInfo string `json:"disk_io_info"`
|
||||||
ReportTime string `json:"report_time"`
|
ReportTime string `json:"report_time"`
|
||||||
AgentVersion string `json:"agent_version"`
|
AgentVersion string `json:"agent_version"`
|
||||||
AgentStatus string `json:"agent_status"`
|
AgentStatus string `json:"agent_status"`
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
|
Kernel string `json:"kernel"` // 内核版本
|
||||||
|
Uptime int64 `json:"uptime"` // 运行秒数
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
|
HasAuth bool `json:"has_auth"` // 是否有 SSH 凭据
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentReportRequest Agent 上报请求体 // Logan.Li
|
// AgentReportRequest Agent 上报请求体 // Logan.Li
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentTask Agent 远程任务 // Logan.Li
|
// AgentTask Agent 远程任务 // Logan.Li
|
||||||
@@ -22,11 +21,10 @@ type AgentTask struct {
|
|||||||
PickedAt *time.Time `json:"picked_at"` // Agent 拉取时间
|
PickedAt *time.Time `json:"picked_at"` // Agent 拉取时间
|
||||||
FinishedAt *time.Time `json:"finished_at"` // 完成时间
|
FinishedAt *time.Time `json:"finished_at"` // 完成时间
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AgentTask) TableName() string {
|
func (AgentTask) TableName() string {
|
||||||
return "agent_tasks"
|
return "monitor_agent_tasks"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentRule 告警规则(基于 Agent 数据) // Logan.Li
|
// AgentRule 告警规则(基于 Agent 数据) // Logan.Li
|
||||||
@@ -42,11 +40,10 @@ type AgentRule struct {
|
|||||||
LastTriggered *time.Time `json:"last_triggered"`
|
LastTriggered *time.Time `json:"last_triggered"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AgentRule) TableName() string {
|
func (AgentRule) TableName() string {
|
||||||
return "agent_rules"
|
return "monitor_agent_rules"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentAlert Agent 告警事件 // Logan.Li
|
// AgentAlert Agent 告警事件 // Logan.Li
|
||||||
@@ -63,9 +60,8 @@ type AgentAlert struct {
|
|||||||
ResolvedAt *time.Time `json:"resolved_at"`
|
ResolvedAt *time.Time `json:"resolved_at"`
|
||||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AgentAlert) TableName() string {
|
func (AgentAlert) TableName() string {
|
||||||
return "agent_alerts"
|
return "monitor_agent_alerts"
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertRule 告警规则模型
|
// AlertRule 告警规则模型
|
||||||
@@ -22,12 +21,11 @@ type AlertRule struct {
|
|||||||
NotifyChannels string `gorm:"type:text" json:"notify_channels"` // JSON
|
NotifyChannels string `gorm:"type:text" json:"notify_channels"` // JSON
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (AlertRule) TableName() string {
|
func (AlertRule) TableName() string {
|
||||||
return "alert_rules"
|
return "monitor_alert_rules"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertEvent 告警事件模型
|
// AlertEvent 告警事件模型
|
||||||
@@ -55,7 +53,7 @@ type AlertEvent struct {
|
|||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (AlertEvent) TableName() string {
|
func (AlertEvent) TableName() string {
|
||||||
return "alert_events"
|
return "monitor_alert_events"
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifyChannel 通知渠道模型
|
// NotifyChannel 通知渠道模型
|
||||||
@@ -68,12 +66,11 @@ type NotifyChannel struct {
|
|||||||
TestStatus string `gorm:"size:32" json:"test_status"`
|
TestStatus string `gorm:"size:32" json:"test_status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (NotifyChannel) TableName() string {
|
func (NotifyChannel) TableName() string {
|
||||||
return "notify_channels"
|
return "monitor_notify_channels"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetricData 监控指标数据
|
// MetricData 监控指标数据
|
||||||
@@ -89,7 +86,7 @@ type MetricData struct {
|
|||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (MetricData) TableName() string {
|
func (MetricData) TableName() string {
|
||||||
return "metric_data"
|
return "monitor_metric_data"
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAlertRuleRequest 创建告警规则请求
|
// CreateAlertRuleRequest 创建告警规则请求
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ type AlertRuleV2 struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (AlertRuleV2) TableName() string {
|
func (AlertRuleV2) TableName() string {
|
||||||
return "alert_rules_v2"
|
return "monitor_alert_rules_v2"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertCondition 告警条件定义
|
// AlertCondition 告警条件定义
|
||||||
@@ -109,6 +109,11 @@ type AlertCondition struct {
|
|||||||
Window int `json:"window"` // 条件级聚合窗口
|
Window int `json:"window"` // 条件级聚合窗口
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (AlertCondition) TableName() string {
|
||||||
|
return "monitor_alert_conditions"
|
||||||
|
}
|
||||||
|
|
||||||
// AlertConditionGroup 告警条件组
|
// AlertConditionGroup 告警条件组
|
||||||
type AlertConditionGroup struct {
|
type AlertConditionGroup struct {
|
||||||
Conditions []AlertCondition `json:"conditions"` // 条件列表
|
Conditions []AlertCondition `json:"conditions"` // 条件列表
|
||||||
@@ -159,7 +164,7 @@ type AlertNotifyTemplate struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (AlertNotifyTemplate) TableName() string {
|
func (AlertNotifyTemplate) TableName() string {
|
||||||
return "alert_notify_templates"
|
return "monitor_alert_notify_templates"
|
||||||
}
|
}
|
||||||
|
|
||||||
// TemplateVariable 模板变量定义
|
// TemplateVariable 模板变量定义
|
||||||
@@ -209,7 +214,7 @@ type AlertUpgrade struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (AlertUpgrade) TableName() string {
|
func (AlertUpgrade) TableName() string {
|
||||||
return "alert_upgrades"
|
return "monitor_alert_upgrades"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertUpgradeRule 告警升级规则
|
// AlertUpgradeRule 告警升级规则
|
||||||
@@ -235,7 +240,7 @@ type AlertUpgradeRecord struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (AlertUpgradeRecord) TableName() string {
|
func (AlertUpgradeRecord) TableName() string {
|
||||||
return "alert_upgrade_records"
|
return "monitor_alert_upgrade_records"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -278,7 +283,7 @@ type AlertSilence struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (AlertSilence) TableName() string {
|
func (AlertSilence) TableName() string {
|
||||||
return "alert_silences"
|
return "monitor_alert_silences"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertSilenceMatcher 静默匹配条件
|
// AlertSilenceMatcher 静默匹配条件
|
||||||
@@ -349,7 +354,7 @@ type AlertEventV2 struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (AlertEventV2) TableName() string {
|
func (AlertEventV2) TableName() string {
|
||||||
return "alert_events_v2"
|
return "monitor_alert_events_v2"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertRuleV2CreateRequest 告警规则创建请求
|
// AlertRuleV2CreateRequest 告警规则创建请求
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ApprovalTemplate 审批模板模型
|
// ApprovalTemplate 审批模板模型
|
||||||
@@ -18,12 +17,11 @@ type ApprovalTemplate struct {
|
|||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (ApprovalTemplate) TableName() string {
|
func (ApprovalTemplate) TableName() string {
|
||||||
return "approval_templates"
|
return "sys_approval_templates"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApprovalProcess 审批流程模型
|
// ApprovalProcess 审批流程模型
|
||||||
@@ -41,7 +39,6 @@ type ApprovalProcess struct {
|
|||||||
RejectReason string `gorm:"size:500" json:"reject_reason"`
|
RejectReason string `gorm:"size:500" json:"reject_reason"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Template *ApprovalTemplate `gorm:"foreignKey:TemplateID" json:"template,omitempty"`
|
Template *ApprovalTemplate `gorm:"foreignKey:TemplateID" json:"template,omitempty"`
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type AuditLog struct {
|
|||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (AuditLog) TableName() string {
|
func (AuditLog) TableName() string {
|
||||||
return "audit_logs"
|
return "sys_audit_logs"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditLogQuery 审计日志查询
|
// AuditLogQuery 审计日志查询
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CronJob 定时任务模型
|
// CronJob 定时任务模型
|
||||||
@@ -27,7 +26,6 @@ type CronJob struct {
|
|||||||
NotifyOn string `gorm:"size:32;default:failure" json:"notify_on"` // always, failure, success
|
NotifyOn string `gorm:"size:32;default:failure" json:"notify_on"` // always, failure, success
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Script *Script `json:"script,omitempty"`
|
Script *Script `json:"script,omitempty"`
|
||||||
@@ -54,12 +52,11 @@ type Script struct {
|
|||||||
UpdatedBy uint `json:"updated_by"`
|
UpdatedBy uint `json:"updated_by"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (Script) TableName() string {
|
func (Script) TableName() string {
|
||||||
return "scripts"
|
return "ops_scripts"
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskExecution 任务执行记录
|
// TaskExecution 任务执行记录
|
||||||
@@ -83,7 +80,7 @@ type TaskExecution struct {
|
|||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (TaskExecution) TableName() string {
|
func (TaskExecution) TableName() string {
|
||||||
return "task_executions"
|
return "ops_task_executions"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playbook Ansible Playbook模型
|
// Playbook Ansible Playbook模型
|
||||||
@@ -103,12 +100,11 @@ type Playbook struct {
|
|||||||
UpdatedBy uint `json:"updated_by"`
|
UpdatedBy uint `json:"updated_by"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (Playbook) TableName() string {
|
func (Playbook) TableName() string {
|
||||||
return "playbooks"
|
return "ops_playbooks"
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePlaybookRequest 创建Playbook请求
|
// CreatePlaybookRequest 创建Playbook请求
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CloudBilling 云账单模型 - 每日成本记录
|
// CloudBilling 云账单模型 - 每日成本记录
|
||||||
@@ -21,11 +20,10 @@ type CloudBilling struct {
|
|||||||
Description string `gorm:"size:500" json:"description"`
|
Description string `gorm:"size:500" json:"description"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (CloudBilling) TableName() string {
|
func (CloudBilling) TableName() string {
|
||||||
return "cloud_billings"
|
return "asset_cloud_billings"
|
||||||
}
|
}
|
||||||
|
|
||||||
// BillingAlertRule 账单预警规则
|
// BillingAlertRule 账单预警规则
|
||||||
@@ -41,11 +39,10 @@ type BillingAlertRule struct {
|
|||||||
LastTriggered *time.Time `json:"last_triggered"`
|
LastTriggered *time.Time `json:"last_triggered"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (BillingAlertRule) TableName() string {
|
func (BillingAlertRule) TableName() string {
|
||||||
return "billing_alert_rules"
|
return "monitor_billing_alert_rules"
|
||||||
}
|
}
|
||||||
|
|
||||||
// BillingStats 账单统计
|
// BillingStats 账单统计
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pipeline CI/CD流水线模型
|
// Pipeline CI/CD流水线模型
|
||||||
@@ -26,12 +25,11 @@ type Pipeline struct {
|
|||||||
FailCount int `gorm:"default:0" json:"fail_count"`
|
FailCount int `gorm:"default:0" json:"fail_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (Pipeline) TableName() string {
|
func (Pipeline) TableName() string {
|
||||||
return "pipelines"
|
return "ops_pipelines"
|
||||||
}
|
}
|
||||||
|
|
||||||
// PipelineRun 流水线运行记录
|
// PipelineRun 流水线运行记录
|
||||||
@@ -81,12 +79,11 @@ type AppRelease struct {
|
|||||||
Description string `gorm:"size:500" json:"description"`
|
Description string `gorm:"size:500" json:"description"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (AppRelease) TableName() string {
|
func (AppRelease) TableName() string {
|
||||||
return "app_releases"
|
return "deploy_app_releases"
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePipelineRequest 创建流水线请求
|
// CreatePipelineRequest 创建流水线请求
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credential 凭据管理模型(SSH密钥/API Token/密码等统一管理)
|
||||||
|
type Credential struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Name string `gorm:"size:128;not null" json:"name"` // 凭据名称
|
||||||
|
Type string `gorm:"size:32;not null" json:"type"` // ssh_key, api_token, password, access_key
|
||||||
|
Content string `gorm:"type:text;not null" json:"-"` // AES加密后的内容,API不返回
|
||||||
|
Description string `gorm:"size:500" json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Credential) TableName() string {
|
||||||
|
return "iam_credentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialCreateRequest 创建凭据请求
|
||||||
|
type CredentialCreateRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Type string `json:"type" binding:"required,oneof=ssh_key api_token password access_key"`
|
||||||
|
Content string `json:"content" binding:"required"` // 明文,后端加密存储
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialUpdateRequest 更新凭据请求
|
||||||
|
type CredentialUpdateRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Type string `json:"type" binding:"required,oneof=ssh_key api_token password access_key"`
|
||||||
|
Content string `json:"content"` // 空则不更新内容
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialListResponse 列表返回(内容脱敏)
|
||||||
|
type CredentialListResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Preview string `json:"preview"` // 内容预览(前8+后4字符)
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// K8sCluster K8s集群模型
|
// K8sCluster K8s集群模型
|
||||||
@@ -29,12 +28,11 @@ type K8sCluster struct {
|
|||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (K8sCluster) TableName() string {
|
func (K8sCluster) TableName() string {
|
||||||
return "k8s_clusters"
|
return "container_clusters"
|
||||||
}
|
}
|
||||||
|
|
||||||
// K8sNode K8s节点模型
|
// K8sNode K8s节点模型
|
||||||
@@ -58,7 +56,6 @@ type K8sNode struct {
|
|||||||
Labels string `gorm:"type:text" json:"labels"`
|
Labels string `gorm:"type:text" json:"labels"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Cluster *K8sCluster `json:"cluster,omitempty"`
|
Cluster *K8sCluster `json:"cluster,omitempty"`
|
||||||
@@ -85,7 +82,6 @@ type K8sPod struct {
|
|||||||
Labels string `gorm:"type:text" json:"labels"`
|
Labels string `gorm:"type:text" json:"labels"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Cluster *K8sCluster `json:"cluster,omitempty"`
|
Cluster *K8sCluster `json:"cluster,omitempty"`
|
||||||
@@ -110,7 +106,6 @@ type K8sDeployment struct {
|
|||||||
Labels string `gorm:"type:text" json:"labels"`
|
Labels string `gorm:"type:text" json:"labels"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Cluster *K8sCluster `json:"cluster,omitempty"`
|
Cluster *K8sCluster `json:"cluster,omitempty"`
|
||||||
@@ -135,7 +130,6 @@ type K8sService struct {
|
|||||||
Labels string `gorm:"type:text" json:"labels"`
|
Labels string `gorm:"type:text" json:"labels"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Cluster *K8sCluster `json:"cluster,omitempty"`
|
Cluster *K8sCluster `json:"cluster,omitempty"`
|
||||||
@@ -159,7 +153,6 @@ type K8sIngress struct {
|
|||||||
Labels string `gorm:"type:text" json:"labels"`
|
Labels string `gorm:"type:text" json:"labels"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Cluster *K8sCluster `json:"cluster,omitempty"`
|
Cluster *K8sCluster `json:"cluster,omitempty"`
|
||||||
@@ -202,3 +195,8 @@ type TopologyLayout struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (TopologyLayout) TableName() string {
|
||||||
|
return "container_topology_layouts"
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ type LLMConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名 // Logan.Li
|
// TableName 表名 // Logan.Li
|
||||||
func (LLMConfig) TableName() string { return "llm_configs" }
|
func (LLMConfig) TableName() string { return "sys_llm_configs" }
|
||||||
|
|
||||||
// SaveLLMConfigRequest 保存 LLM 配置请求 // Logan.Li
|
// SaveLLMConfigRequest 保存 LLM 配置请求 // Logan.Li
|
||||||
type SaveLLMConfigRequest struct {
|
type SaveLLMConfigRequest struct {
|
||||||
@@ -63,4 +63,4 @@ type ChatHistory struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名 // Logan.Li
|
// TableName 表名 // Logan.Li
|
||||||
func (ChatHistory) TableName() string { return "chat_histories" }
|
func (ChatHistory) TableName() string { return "ai_chat_histories" }
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// MetricsAggregate 指标聚合数据(Redis 热数据 → MySQL 时序聚合)// Logan.Li
|
||||||
|
// 替代旧名 MetricsKline,OHLC 对监控无意义,改用 max/min/avg/p95
|
||||||
|
type MetricsAggregate struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Hostname string `gorm:"size:255;index:idx_agg;not null" json:"hostname"`
|
||||||
|
Metric string `gorm:"size:32;index:idx_agg;not null" json:"metric"` // cpu / mem / load1 / disk / net_rx / net_tx
|
||||||
|
Interval string `gorm:"size:8;index:idx_agg;not null" json:"interval"` // 1m / 5m / 1h / 6h / 1d
|
||||||
|
Avg float64 `gorm:"type:decimal(10,2)" json:"avg"`
|
||||||
|
MaxV float64 `gorm:"column:max_v;type:decimal(10,2)" json:"max"`
|
||||||
|
MinV float64 `gorm:"column:min_v;type:decimal(10,2)" json:"min"`
|
||||||
|
P95 float64 `gorm:"type:decimal(10,2)" json:"p95"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
BucketTime time.Time `gorm:"index:idx_agg;not null" json:"bucket_time"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MetricsAggregate) TableName() string {
|
||||||
|
return "monitor_metrics_aggregate"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateReq 聚合触发请求
|
||||||
|
type AggregateReq struct {
|
||||||
|
Hostname string `json:"hostname" binding:"required"`
|
||||||
|
Metric string `json:"metric" binding:"required"` // cpu / mem / load1
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateQueryReq 聚合查询参数
|
||||||
|
type AggregateQueryReq struct {
|
||||||
|
Hostname string `form:"hostname" binding:"required"`
|
||||||
|
Metric string `form:"metric" binding:"required"`
|
||||||
|
Interval string `form:"interval" binding:"required"`
|
||||||
|
From string `form:"from" binding:"required"` // RFC3339
|
||||||
|
To string `form:"to" binding:"required"` // RFC3339
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// MetricsKline 指标K线聚合数据(由 Redis 热数据聚合到 MySQL)// Logan.Li
|
|
||||||
type MetricsKline struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
Hostname string `gorm:"size:255;index:idx_kline;not null" json:"hostname"`
|
|
||||||
Metric string `gorm:"size:32;index:idx_kline;not null" json:"metric"` // cpu / mem / load1
|
|
||||||
Interval string `gorm:"size:8;index:idx_kline;not null" json:"interval"` // 1m / 3m / 1h / 6h / 1d
|
|
||||||
Open float64 `gorm:"type:decimal(10,2)" json:"open"`
|
|
||||||
High float64 `gorm:"type:decimal(10,2)" json:"high"`
|
|
||||||
Low float64 `gorm:"type:decimal(10,2)" json:"low"`
|
|
||||||
Close float64 `gorm:"type:decimal(10,2)" json:"close"`
|
|
||||||
Avg float64 `gorm:"type:decimal(10,2)" json:"avg"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
BucketTime time.Time `gorm:"index:idx_kline;not null" json:"bucket_time"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (MetricsKline) TableName() string {
|
|
||||||
return "metrics_klines"
|
|
||||||
}
|
|
||||||
|
|
||||||
// AggregateKlineReq 聚合请求体
|
|
||||||
type AggregateKlineReq struct {
|
|
||||||
Hostname string `json:"hostname" binding:"required"`
|
|
||||||
Metric string `json:"metric" binding:"required"` // cpu / mem / load1
|
|
||||||
}
|
|
||||||
|
|
||||||
// KlineQueryReq K线查询参数(Query 参数)
|
|
||||||
type KlineQueryReq struct {
|
|
||||||
Hostname string `form:"hostname" binding:"required"`
|
|
||||||
Metric string `form:"metric" binding:"required"`
|
|
||||||
Interval string `form:"interval" binding:"required"`
|
|
||||||
From string `form:"from" binding:"required"` // RFC3339
|
|
||||||
To string `form:"to" binding:"required"` // RFC3339
|
|
||||||
}
|
|
||||||
@@ -160,7 +160,7 @@ type MetricHistory struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (MetricHistory) TableName() string {
|
func (MetricHistory) TableName() string {
|
||||||
return "metric_histories"
|
return "monitor_metric_histories"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetricValueQuery 指标值查询参数
|
// MetricValueQuery 指标值查询参数
|
||||||
@@ -202,7 +202,7 @@ type MetricTag struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (MetricTag) TableName() string {
|
func (MetricTag) TableName() string {
|
||||||
return "metric_tags"
|
return "monitor_metric_tags"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -351,5 +351,5 @@ type HostGroup struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (HostGroup) TableName() string {
|
func (HostGroup) TableName() string {
|
||||||
return "host_groups"
|
return "asset_host_groups"
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NavigationGroup 导航分组模型
|
// NavigationGroup 导航分组模型
|
||||||
@@ -18,12 +17,11 @@ type NavigationGroup struct {
|
|||||||
LinkCount int `gorm:"default:0" json:"link_count"`
|
LinkCount int `gorm:"default:0" json:"link_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (NavigationGroup) TableName() string {
|
func (NavigationGroup) TableName() string {
|
||||||
return "navigation_groups"
|
return "sys_navigation_groups"
|
||||||
}
|
}
|
||||||
|
|
||||||
// NavigationLink 导航链接模型
|
// NavigationLink 导航链接模型
|
||||||
@@ -40,7 +38,6 @@ type NavigationLink struct {
|
|||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Group *NavigationGroup `json:"group,omitempty"`
|
Group *NavigationGroup `json:"group,omitempty"`
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AnsiblePlaybook Playbook 模型 // Logan.Li
|
// AnsiblePlaybook Playbook 模型 // Logan.Li
|
||||||
@@ -17,10 +16,9 @@ type AnsiblePlaybook struct {
|
|||||||
CreatedBy string `gorm:"size:255" json:"created_by"`
|
CreatedBy string `gorm:"size:255" json:"created_by"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AnsiblePlaybook) TableName() string { return "ansible_playbooks" }
|
func (AnsiblePlaybook) TableName() string { return "ops_ansible_playbooks" }
|
||||||
|
|
||||||
// AnsibleJob 执行任务记录 // Logan.Li
|
// AnsibleJob 执行任务记录 // Logan.Li
|
||||||
type AnsibleJob struct {
|
type AnsibleJob struct {
|
||||||
@@ -37,10 +35,9 @@ type AnsibleJob struct {
|
|||||||
CompletedAt *time.Time `json:"completed_at"`
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AnsibleJob) TableName() string { return "ansible_jobs" }
|
func (AnsibleJob) TableName() string { return "ops_ansible_jobs" }
|
||||||
|
|
||||||
// AnsibleInventory 主机清单 // Logan.Li
|
// AnsibleInventory 主机清单 // Logan.Li
|
||||||
type AnsibleInventory struct {
|
type AnsibleInventory struct {
|
||||||
@@ -51,10 +48,9 @@ type AnsibleInventory struct {
|
|||||||
CreatedBy string `gorm:"size:255" json:"created_by"`
|
CreatedBy string `gorm:"size:255" json:"created_by"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AnsibleInventory) TableName() string { return "ansible_inventories" }
|
func (AnsibleInventory) TableName() string { return "ops_ansible_inventories" }
|
||||||
|
|
||||||
// LogSource 日志源 // Logan.Li
|
// LogSource 日志源 // Logan.Li
|
||||||
type LogSource struct {
|
type LogSource struct {
|
||||||
@@ -67,10 +63,9 @@ type LogSource struct {
|
|||||||
LastSeen *time.Time `json:"last_seen"`
|
LastSeen *time.Time `json:"last_seen"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LogSource) TableName() string { return "log_sources" }
|
func (LogSource) TableName() string { return "sys_log_sources" }
|
||||||
|
|
||||||
// LogEntry 日志条目 // Logan.Li
|
// LogEntry 日志条目 // Logan.Li
|
||||||
type LogEntry struct {
|
type LogEntry struct {
|
||||||
@@ -84,7 +79,6 @@ type LogEntry struct {
|
|||||||
TraceID string `gorm:"size:100;index" json:"trace_id"`
|
TraceID string `gorm:"size:100;index" json:"trace_id"`
|
||||||
Raw string `gorm:"type:text" json:"raw"`
|
Raw string `gorm:"type:text" json:"raw"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LogEntry) TableName() string { return "log_entries" }
|
func (LogEntry) TableName() string { return "sys_log_entries" }
|
||||||
@@ -51,7 +51,7 @@ type PrometheusDataSource struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (PrometheusDataSource) TableName() string {
|
func (PrometheusDataSource) TableName() string {
|
||||||
return "prometheus_datasources"
|
return "monitor_prometheus_datasources"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResourceModel 资源模型 (CMDB 模型定义)
|
// ResourceModel 资源模型 (CMDB 模型定义)
|
||||||
@@ -17,9 +16,8 @@ type ResourceModel struct {
|
|||||||
Icon string `gorm:"size:64" json:"icon"`
|
Icon string `gorm:"size:64" json:"icon"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ResourceModel) TableName() string {
|
func (ResourceModel) TableName() string {
|
||||||
return "resource_models"
|
return "asset_resource_models"
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server 服务器模型
|
// Server 服务器模型
|
||||||
@@ -18,6 +17,7 @@ type Server struct {
|
|||||||
SSHKey string `gorm:"type:text" json:"-"`
|
SSHKey string `gorm:"type:text" json:"-"`
|
||||||
AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key
|
AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key
|
||||||
OS string `gorm:"size:64" json:"os"`
|
OS string `gorm:"size:64" json:"os"`
|
||||||
|
Kernel string `gorm:"size:64" json:"kernel"`
|
||||||
OSVersion string `gorm:"size:64" json:"os_version"`
|
OSVersion string `gorm:"size:64" json:"os_version"`
|
||||||
Arch string `gorm:"size:32" json:"arch"`
|
Arch string `gorm:"size:32" json:"arch"`
|
||||||
CPU int `json:"cpu"`
|
CPU int `json:"cpu"`
|
||||||
@@ -30,6 +30,7 @@ type Server struct {
|
|||||||
Uptime int64 `json:"uptime"` // seconds
|
Uptime int64 `json:"uptime"` // seconds
|
||||||
AgentVersion string `gorm:"size:32" json:"agent_version"`
|
AgentVersion string `gorm:"size:32" json:"agent_version"`
|
||||||
AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline
|
AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline
|
||||||
|
HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li
|
||||||
RegionID *uint `json:"region_id"`
|
RegionID *uint `json:"region_id"`
|
||||||
CloudID *uint `json:"cloud_id"`
|
CloudID *uint `json:"cloud_id"`
|
||||||
Tags string `gorm:"type:text" json:"tags"` // JSON
|
Tags string `gorm:"type:text" json:"tags"` // JSON
|
||||||
@@ -37,7 +38,6 @@ type Server struct {
|
|||||||
LastCheckAt *time.Time `json:"last_check_at"`
|
LastCheckAt *time.Time `json:"last_check_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Region *Region `json:"region,omitempty"`
|
Region *Region `json:"region,omitempty"`
|
||||||
@@ -46,7 +46,7 @@ type Server struct {
|
|||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (Server) TableName() string {
|
func (Server) TableName() string {
|
||||||
return "servers"
|
return "asset_servers"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloud 云平台模型
|
// Cloud 云平台模型
|
||||||
@@ -60,12 +60,11 @@ type Cloud struct {
|
|||||||
Description string `gorm:"size:500" json:"description"`
|
Description string `gorm:"size:500" json:"description"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (Cloud) TableName() string {
|
func (Cloud) TableName() string {
|
||||||
return "clouds"
|
return "asset_clouds"
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloudAccount 云账号模型
|
// CloudAccount 云账号模型
|
||||||
@@ -82,12 +81,11 @@ type CloudAccount struct {
|
|||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (CloudAccount) TableName() string {
|
func (CloudAccount) TableName() string {
|
||||||
return "cloud_accounts"
|
return "asset_cloud_accounts"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Region 区域模型
|
// Region 区域模型
|
||||||
@@ -102,12 +100,11 @@ type Region struct {
|
|||||||
VPCCount int `gorm:"default:0" json:"vpc_count"`
|
VPCCount int `gorm:"default:0" json:"vpc_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (Region) TableName() string {
|
func (Region) TableName() string {
|
||||||
return "regions"
|
return "asset_regions"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Domain 域名模型
|
// Domain 域名模型
|
||||||
@@ -123,12 +120,11 @@ type Domain struct {
|
|||||||
SSLExpireDate *time.Time `json:"ssl_expire_date"`
|
SSLExpireDate *time.Time `json:"ssl_expire_date"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (Domain) TableName() string {
|
func (Domain) TableName() string {
|
||||||
return "domains"
|
return "asset_domains"
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateServerRequest 创建服务器请求
|
// CreateServerRequest 创建服务器请求
|
||||||
@@ -191,6 +187,7 @@ type BatchCreateServerRequest struct {
|
|||||||
Servers []BatchServerItem `json:"servers"`
|
Servers []BatchServerItem `json:"servers"`
|
||||||
RegionID *uint `json:"region_id"`
|
RegionID *uint `json:"region_id"`
|
||||||
AuthType string `json:"auth_type"`
|
AuthType string `json:"auth_type"`
|
||||||
|
CredentialID *uint `json:"credential_id"` // 凭据ID,非空则解密后写入每台服务器
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchCreateResult 批量导入结果 // Logan.Li
|
// BatchCreateResult 批量导入结果 // Logan.Li
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ type SiteTask struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (SiteTask) TableName() string {
|
func (SiteTask) TableName() string {
|
||||||
return "site_tasks"
|
return "ops_site_tasks"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -138,7 +138,7 @@ type SiteResult struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (SiteResult) TableName() string {
|
func (SiteResult) TableName() string {
|
||||||
return "site_results"
|
return "ops_site_results"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -198,7 +198,7 @@ type SiteAlert struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (SiteAlert) TableName() string {
|
func (SiteAlert) TableName() string {
|
||||||
return "site_alerts"
|
return "ops_site_alerts"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SiteTaskCreateRequest 拨测任务创建请求
|
// SiteTaskCreateRequest 拨测任务创建请求
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ type SNMPDevice struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (SNMPDevice) TableName() string {
|
func (SNMPDevice) TableName() string {
|
||||||
return "snmp_devices"
|
return "ops_snmp_devices"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -120,7 +120,7 @@ type SNMPTemplate struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (SNMPTemplate) TableName() string {
|
func (SNMPTemplate) TableName() string {
|
||||||
return "snmp_templates"
|
return "ops_snmp_templates"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SNMPTemplateOID OID模板条目表
|
// SNMPTemplateOID OID模板条目表
|
||||||
@@ -142,7 +142,7 @@ type SNMPTemplateOID struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (SNMPTemplateOID) TableName() string {
|
func (SNMPTemplateOID) TableName() string {
|
||||||
return "snmp_template_oids"
|
return "ops_snmp_template_oids"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -170,7 +170,7 @@ type SNMPMetric struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (SNMPMetric) TableName() string {
|
func (SNMPMetric) TableName() string {
|
||||||
return "snmp_metrics"
|
return "ops_snmp_metrics"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -199,7 +199,7 @@ type SNMPInterface struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (SNMPInterface) TableName() string {
|
func (SNMPInterface) TableName() string {
|
||||||
return "snmp_interfaces"
|
return "ops_snmp_interfaces"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type TerminalSession struct {
|
|||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (TerminalSession) TableName() string {
|
func (TerminalSession) TableName() string {
|
||||||
return "terminal_sessions"
|
return "sys_terminal_sessions"
|
||||||
}
|
}
|
||||||
|
|
||||||
// TerminalSessionQuery 终端会话查询
|
// TerminalSessionQuery 终端会话查询
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// User 用户模型
|
// User 用户模型
|
||||||
@@ -21,7 +20,6 @@ type User struct {
|
|||||||
LastLogin *time.Time `json:"last_login"`
|
LastLogin *time.Time `json:"last_login"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Roles []Role `gorm:"many2many:user_roles;" json:"roles,omitempty"`
|
Roles []Role `gorm:"many2many:user_roles;" json:"roles,omitempty"`
|
||||||
@@ -29,7 +27,7 @@ type User struct {
|
|||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (User) TableName() string {
|
func (User) TableName() string {
|
||||||
return "users"
|
return "iam_users"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role 角色模型
|
// Role 角色模型
|
||||||
@@ -41,7 +39,6 @@ type Role struct {
|
|||||||
Status int `gorm:"default:1" json:"status"`
|
Status int `gorm:"default:1" json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Permissions []Permission `gorm:"many2many:role_permissions;" json:"permissions,omitempty"`
|
Permissions []Permission `gorm:"many2many:role_permissions;" json:"permissions,omitempty"`
|
||||||
@@ -49,7 +46,7 @@ type Role struct {
|
|||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (Role) TableName() string {
|
func (Role) TableName() string {
|
||||||
return "roles"
|
return "iam_roles"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permission 权限模型
|
// Permission 权限模型
|
||||||
@@ -65,12 +62,11 @@ type Permission struct {
|
|||||||
Status int `gorm:"default:1" json:"status"`
|
Status int `gorm:"default:1" json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
func (Permission) TableName() string {
|
func (Permission) TableName() string {
|
||||||
return "permissions"
|
return "iam_permissions"
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginRequest 登录请求
|
// LoginRequest 登录请求
|
||||||
@@ -143,4 +139,4 @@ type LDAPConfig struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LDAPConfig) TableName() string { return "ldap_configs" }
|
func (LDAPConfig) TableName() string { return "iam_ldap_configs" }
|
||||||
@@ -34,7 +34,7 @@ type ZabbixDataSource struct {
|
|||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (ZabbixDataSource) TableName() string {
|
func (ZabbixDataSource) TableName() string {
|
||||||
return "zabbix_datasources"
|
return "monitor_zabbix_datasources"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecretBox AES-256-GCM 加密器,用 sha256(key) 派生 32 字节密钥
|
||||||
|
type SecretBox struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSecretBox 创建加密器
|
||||||
|
func NewSecretBox(masterKey string) *SecretBox {
|
||||||
|
h := sha256.Sum256([]byte(masterKey))
|
||||||
|
return &SecretBox{key: h[:]}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt 加密明文,返回 base64 编码的密文(含 12 字节 nonce 前缀)
|
||||||
|
func (s *SecretBox) Encrypt(plaintext string) (string, error) {
|
||||||
|
block, err := aes.NewCipher(s.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt 解密密文,plaintext 应为 Encrypt 的返回值
|
||||||
|
func (s *SecretBox) Decrypt(cipherB64 string) (string, error) {
|
||||||
|
ciphertext, err := base64.StdEncoding.DecodeString(cipherB64)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(s.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(ciphertext) < nonceSize {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
@@ -106,7 +106,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
&models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
||||||
&models.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
&models.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
||||||
&models.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
|
&models.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
|
||||||
&models.MetricsKline{}, // 指标K线聚合(Redis 热数据 → MySQL) // Logan.Li
|
&models.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
|
||||||
&models.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li
|
&models.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li
|
||||||
&models.MonitorMetric{}, // 监控指标定义 // Logan.Li
|
&models.MonitorMetric{}, // 监控指标定义 // Logan.Li
|
||||||
&models.MetricValue{}, // 指标当前值 // Logan.Li
|
&models.MetricValue{}, // 指标当前值 // Logan.Li
|
||||||
@@ -132,6 +132,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.AlertEventV2{}, // 告警事件V2 // Logan.Li
|
&models.AlertEventV2{}, // 告警事件V2 // Logan.Li
|
||||||
&models.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
|
&models.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
|
||||||
&models.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
|
&models.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
|
||||||
|
&models.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { AppReleaseManager } from '@/components/deploy/AppRelease';
|
|||||||
import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow';
|
import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow';
|
||||||
import { ScriptManager } from '@/components/automation/ScriptManager';
|
import { ScriptManager } from '@/components/automation/ScriptManager';
|
||||||
import { SystemManager } from '@/components/system/SystemManager';
|
import { SystemManager } from '@/components/system/SystemManager';
|
||||||
|
import { CredentialManager } from '@/components/system/CredentialManager';
|
||||||
import { PipelineManager } from '@/components/deploy/PipelineManager';
|
import { PipelineManager } from '@/components/deploy/PipelineManager';
|
||||||
import { ReleaseManager } from '@/components/deploy/ReleaseManager';
|
import { ReleaseManager } from '@/components/deploy/ReleaseManager';
|
||||||
import { CronJobManager } from '@/components/automation/CronJobManager';
|
import { CronJobManager } from '@/components/automation/CronJobManager';
|
||||||
@@ -30,6 +31,7 @@ import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer';
|
|||||||
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
|
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
|
||||||
import AlertCenter from '@/components/monitor/alert-center/AlertCenter';
|
import AlertCenter from '@/components/monitor/alert-center/AlertCenter';
|
||||||
import TraceManager from '@/components/monitor/trace/TraceManager';
|
import TraceManager from '@/components/monitor/trace/TraceManager';
|
||||||
|
import { HostMonitor } from '@/pages/HostMonitor';
|
||||||
import HostMonitorDashboard from '@/components/monitor/dashboard/HostMonitorDashboard';
|
import HostMonitorDashboard from '@/components/monitor/dashboard/HostMonitorDashboard';
|
||||||
import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard';
|
import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard';
|
||||||
import { MonitorCenter } from '@/pages/MonitorCenter';
|
import { MonitorCenter } from '@/pages/MonitorCenter';
|
||||||
@@ -76,6 +78,7 @@ function App() {
|
|||||||
<Route path="datasources" element={<DatasourcePage />} />
|
<Route path="datasources" element={<DatasourcePage />} />
|
||||||
<Route path="trace" element={<TraceManager />} />
|
<Route path="trace" element={<TraceManager />} />
|
||||||
<Route path="dashboards" element={<GrafanaDashboard />} />
|
<Route path="dashboards" element={<GrafanaDashboard />} />
|
||||||
|
<Route path="host-monitor" element={<HostMonitor />} />
|
||||||
<Route path="alerts" element={<AlertManager />} />
|
<Route path="alerts" element={<AlertManager />} />
|
||||||
<Route path="alert-center" element={<AlertCenter />} />
|
<Route path="alert-center" element={<AlertCenter />} />
|
||||||
<Route path="logs" element={<AlertManager />} />
|
<Route path="logs" element={<AlertManager />} />
|
||||||
@@ -92,6 +95,7 @@ function App() {
|
|||||||
<Route path="roles" element={<SystemManager />} />
|
<Route path="roles" element={<SystemManager />} />
|
||||||
<Route path="logs-audit" element={<SystemManager />} />
|
<Route path="logs-audit" element={<SystemManager />} />
|
||||||
<Route path="terminal-audit" element={<SystemManager />} />
|
<Route path="terminal-audit" element={<SystemManager />} />
|
||||||
|
<Route path="credentials" element={<CredentialManager />} />
|
||||||
<Route path="settings" element={<SystemManager />} />
|
<Route path="settings" element={<SystemManager />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Copy, Check, Terminal, Download, Settings, Play, Cpu, Zap, Loader2, Server } from 'lucide-react';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Copy, Check, Terminal, Download, Settings, Play, Cpu, Zap, Loader2, Server, Shield } from 'lucide-react';
|
||||||
import { serverApi } from '@/services/api';
|
import { serverApi } from '@/services/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ interface AgentWizardProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
/** 可选:从服务器列表传入的目标服务器信息 */
|
/** 可选:从服务器列表传入的目标服务器信息 */
|
||||||
server?: { id: number; hostname: string; ip: string } | null;
|
server?: { id: number; hostname: string; ip: string; has_auth?: boolean; username?: string; os?: string } | null;
|
||||||
/** SSH一键安装成功回调 */
|
/** SSH一键安装成功回调 */
|
||||||
onInstalled?: (serverId: number) => void;
|
onInstalled?: (serverId: number) => void;
|
||||||
/** SSH一键安装失败回调 */
|
/** SSH一键安装失败回调 */
|
||||||
@@ -42,6 +44,13 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
|||||||
const [showManualInput, setShowManualInput] = useState(false);
|
const [showManualInput, setShowManualInput] = useState(false);
|
||||||
const [sshInstalling, setSshInstalling] = useState(false);
|
const [sshInstalling, setSshInstalling] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState('auto');
|
const [activeTab, setActiveTab] = useState('auto');
|
||||||
|
// 凭据输入(当服务器无认证凭据时显示)
|
||||||
|
const [sshUsername, setSshUsername] = useState('root');
|
||||||
|
const [sshPassword, setSshPassword] = useState('');
|
||||||
|
const [sshKey, setSshKey] = useState('');
|
||||||
|
const [authType, setAuthType] = useState<'password' | 'key'>('password');
|
||||||
|
|
||||||
|
const needsCreds = server && !server.has_auth;
|
||||||
|
|
||||||
// install.sh 通过 Nginx 代理下载(origin:80 → /agent/ → backend:8080)
|
// install.sh 通过 Nginx 代理下载(origin:80 → /agent/ → backend:8080)
|
||||||
const installCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${backend} --token ${token}`;
|
const installCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${backend} --token ${token}`;
|
||||||
@@ -142,11 +151,22 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
|||||||
];
|
];
|
||||||
|
|
||||||
// SSH 一键安装(当从服务器列表传入 server 上下文时可用)
|
// SSH 一键安装(当从服务器列表传入 server 上下文时可用)
|
||||||
|
// 如果服务器未配置凭据,将输入框中的凭据传给后端,首次连接成功后自动保存
|
||||||
const handleSshInstall = async () => {
|
const handleSshInstall = async () => {
|
||||||
if (!server || !token) return;
|
if (!server || !token) return;
|
||||||
|
if (needsCreds && !sshPassword && !sshKey) {
|
||||||
|
toast.error('请填写 SSH 登录凭据(密码或密钥)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSshInstalling(true);
|
setSshInstalling(true);
|
||||||
try {
|
try {
|
||||||
const res: any = await serverApi.installAgent(server.id, token);
|
const creds = needsCreds ? {
|
||||||
|
username: sshUsername,
|
||||||
|
password: sshPassword,
|
||||||
|
ssh_key: sshKey,
|
||||||
|
auth_type: authType,
|
||||||
|
} : undefined;
|
||||||
|
const res: any = await serverApi.installAgent(server.id, token, creds);
|
||||||
const installed = res?.installed || false;
|
const installed = res?.installed || false;
|
||||||
if (installed) {
|
if (installed) {
|
||||||
toast.success(`${server.hostname} Agent 安装成功`);
|
toast.success(`${server.hostname} Agent 安装成功`);
|
||||||
@@ -193,6 +213,12 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
|||||||
SSH一键安装
|
SSH一键安装
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{!server && (
|
||||||
|
<TabsTrigger value="ssh" className="data-[state=active]:bg-slate-700 opacity-50 cursor-not-allowed" disabled>
|
||||||
|
<Zap className="h-4 w-4 mr-1.5" />
|
||||||
|
SSH一键安装
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="manual" className="data-[state=active]:bg-slate-700">
|
<TabsTrigger value="manual" className="data-[state=active]:bg-slate-700">
|
||||||
手动安装
|
手动安装
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -240,7 +266,17 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* SSH 一键安装 — 仅当从服务器列表传入时显示 */}
|
{/* SSH 一键安装 — 无服务器时提示先选择 */}
|
||||||
|
{!server && (
|
||||||
|
<TabsContent value="ssh" className="mt-4 space-y-4">
|
||||||
|
<div className="text-center py-12 text-slate-400">
|
||||||
|
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm font-medium mb-1">未选择目标主机</p>
|
||||||
|
<p className="text-xs">请先在主机列表中勾选一台主机,再点击「安装 Agent」打开此页面</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
{/* SSH 一键安装 — 有服务器时显示 */}
|
||||||
{server && (
|
{server && (
|
||||||
<TabsContent value="ssh" className="mt-4 space-y-4">
|
<TabsContent value="ssh" className="mt-4 space-y-4">
|
||||||
<div className="rounded-lg bg-slate-950 border border-slate-800 p-4">
|
<div className="rounded-lg bg-slate-950 border border-slate-800 p-4">
|
||||||
@@ -251,6 +287,58 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
|||||||
<code className="text-xs text-slate-400">{server.ip}</code>
|
<code className="text-xs text-slate-400">{server.ip}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 未配置凭据时显示输入框 */}
|
||||||
|
{needsCreds && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-amber-950/30 border border-amber-800/40 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-amber-300/80">
|
||||||
|
<Shield className="h-3.5 w-3.5" />
|
||||||
|
<span>该服务器未配置 SSH 凭据,请填写以下信息(首次连接成功后自动保存)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-400">用户名</Label>
|
||||||
|
<Input
|
||||||
|
value={sshUsername}
|
||||||
|
onChange={e => setSshUsername(e.target.value)}
|
||||||
|
placeholder="root"
|
||||||
|
className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-1">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-xs text-slate-400">密码</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={sshPassword}
|
||||||
|
onChange={e => { setSshPassword(e.target.value); setAuthType('password'); }}
|
||||||
|
placeholder="******"
|
||||||
|
className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-500 px-1 pb-1">或</span>
|
||||||
|
<Button variant="ghost" size="sm"
|
||||||
|
onClick={() => { setAuthType('key'); setSshPassword(''); }}
|
||||||
|
className={`text-xs h-7 px-2 ${authType==='key'?'text-blue-400':'text-slate-500'}`}>
|
||||||
|
密钥
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{authType === 'key' && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-400">SSH 私钥(PEM 格式)</Label>
|
||||||
|
<textarea
|
||||||
|
value={sshKey}
|
||||||
|
onChange={e => setSshKey(e.target.value)}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||||
|
rows={3}
|
||||||
|
className="w-full mt-1 bg-slate-900 border border-slate-700 text-slate-200 font-mono text-xs p-2 rounded-md resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-slate-400 mb-4">
|
<p className="text-sm text-slate-400 mb-4">
|
||||||
通过 SSH 远程连接到目标主机,自动下载并安装 Agent,完成后立即启动运行。
|
通过 SSH 远程连接到目标主机,自动下载并安装 Agent,完成后立即启动运行。
|
||||||
</p>
|
</p>
|
||||||
@@ -267,7 +355,7 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Zap className="h-4 w-4 mr-2" />
|
<Zap className="h-4 w-4 mr-2" />
|
||||||
一键安装 Agent
|
{needsCreds ? '连接并安装 Agent(凭据将自动保存)' : '一键安装 Agent'}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ const menuItems: MenuItem[] = [
|
|||||||
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
|
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
|
||||||
{ id: 'logs-audit', name: '操作审计', icon: FileCode, path: '/logs-audit' },
|
{ id: 'logs-audit', name: '操作审计', icon: FileCode, path: '/logs-audit' },
|
||||||
{ id: 'terminal-audit', name: '终端审计', icon: Terminal, path: '/terminal-audit' },
|
{ id: 'terminal-audit', name: '终端审计', icon: Terminal, path: '/terminal-audit' },
|
||||||
|
{ id: 'credentials', name: '凭证配置', icon: Shield, path: '/credentials' },
|
||||||
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
|
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,25 +40,36 @@ const AlertCenter: React.FC = () => {
|
|||||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||||
const [editingRule, setEditingRule] = useState<any>(null);
|
const [editingRule, setEditingRule] = useState<any>(null);
|
||||||
const [quickSetupLoading, setQuickSetupLoading] = useState(false);
|
const [quickSetupLoading, setQuickSetupLoading] = useState(false);
|
||||||
|
// 分页
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize] = useState(10);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
const interval = setInterval(fetchData, 30000);
|
const interval = setInterval(fetchData, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [page, severityFilter, statusFilter]);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
await Promise.all([fetchAlerts(), fetchRules()]);
|
await Promise.all([fetchAlerts(), fetchRules()]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Logan.Li — 获取告警列表
|
// Logan.Li — 获取告警列表(分页)
|
||||||
const fetchAlerts = async () => {
|
const fetchAlerts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await alertCenterApi.getAlerts();
|
const res: any = await alertCenterApi.getAlerts({
|
||||||
const raw = (res.list || []) as any[];
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
severity: severityFilter !== 'all' ? severityFilter : undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
});
|
||||||
|
const raw = (res.list || res.data?.list || []) as any[];
|
||||||
const data: Alert[] = raw.map(mapAlert);
|
const data: Alert[] = raw.map(mapAlert);
|
||||||
setAlerts(data);
|
setAlerts(data);
|
||||||
|
setTotal(res.total || res.data?.total || data.length);
|
||||||
setStats({
|
setStats({
|
||||||
firing: data.filter(a => a.status === 'firing').length,
|
firing: data.filter(a => a.status === 'firing').length,
|
||||||
critical: data.filter(a => a.severity === 'critical' && a.status === 'firing').length,
|
critical: data.filter(a => a.severity === 'critical' && a.status === 'firing').length,
|
||||||
@@ -160,67 +171,64 @@ const AlertCenter: React.FC = () => {
|
|||||||
try { await agentApi.deleteRule(Number(rule.id)); toast.success('已删除'); fetchRules(); } catch { toast.error('删除失败'); }
|
try { await agentApi.deleteRule(Number(rule.id)); toast.success('已删除'); fetchRules(); } catch { toast.error('删除失败'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredAlerts = alerts.filter(alert => {
|
const handleFilterChange = (type: 'severity' | 'status', value: string) => {
|
||||||
const matchSearch = alert.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
if (type === 'severity') setSeverityFilter(value);
|
||||||
alert.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
else setStatusFilter(value);
|
||||||
alert.source.toLowerCase().includes(searchQuery.toLowerCase());
|
setPage(1); // 过滤条件变化时重置到第一页
|
||||||
const matchSeverity = severityFilter === 'all' || alert.severity === severityFilter;
|
};
|
||||||
const matchStatus = statusFilter === 'all' || alert.status === statusFilter;
|
|
||||||
return matchSearch && matchSeverity && matchStatus;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-3">
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 — 紧凑 */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<Card className={stats.firing > 0 ? 'border-red-500 border-2' : ''}>
|
<Card className={stats.firing > 0 ? 'border-red-500 border-2' : ''}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">触发中</p>
|
<p className="text-xs text-muted-foreground">触发中</p>
|
||||||
<p className={`text-2xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
|
<p className={`text-xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`p-3 rounded-lg ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
|
<div className={`p-2 rounded-md ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
|
||||||
<Zap className={`w-5 h-5 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
|
<Zap className={`w-4 h-4 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">严重告警</p>
|
<p className="text-xs text-muted-foreground">严重告警</p>
|
||||||
<p className="text-2xl font-bold text-red-600">{stats.critical}</p>
|
<p className="text-xl font-bold text-red-600">{stats.critical}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-red-100 rounded-lg">
|
<div className="p-2 bg-red-100 rounded-md">
|
||||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">警告告警</p>
|
<p className="text-xs text-muted-foreground">警告告警</p>
|
||||||
<p className="text-2xl font-bold text-orange-600">{stats.warning}</p>
|
<p className="text-xl font-bold text-orange-600">{stats.warning}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-orange-100 rounded-lg">
|
<div className="p-2 bg-orange-100 rounded-md">
|
||||||
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">今日已解决</p>
|
<p className="text-xs text-muted-foreground">今日已解决</p>
|
||||||
<p className="text-2xl font-bold text-green-600">{stats.resolved}</p>
|
<p className="text-xl font-bold text-green-600">{stats.resolved}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-green-100 rounded-lg">
|
<div className="p-2 bg-green-100 rounded-md">
|
||||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -228,27 +236,27 @@ const AlertCenter: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
|
<TabsList className="grid w-full grid-cols-3 lg:w-[360px] h-9">
|
||||||
<TabsTrigger value="alerts">告警列表</TabsTrigger>
|
<TabsTrigger value="alerts" className="text-xs">告警列表</TabsTrigger>
|
||||||
<TabsTrigger value="rules">告警规则</TabsTrigger>
|
<TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger>
|
||||||
<TabsTrigger value="channels">通知渠道</TabsTrigger>
|
<TabsTrigger value="channels" className="text-xs">通知渠道</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 告警列表 Tab */}
|
{/* 告警列表 Tab */}
|
||||||
<TabsContent value="alerts" className="space-y-4">
|
<TabsContent value="alerts" className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
<div className="flex items-center gap-2 flex-1 min-w-[280px]">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索告警标题、描述、来源..."
|
placeholder="搜索告警..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-8 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={severityFilter} onValueChange={setSeverityFilter}>
|
<Select value={severityFilter} onValueChange={(v) => handleFilterChange('severity', v)}>
|
||||||
<SelectTrigger className="w-[120px]">
|
<SelectTrigger className="w-[100px] h-8 text-xs">
|
||||||
<SelectValue placeholder="级别" />
|
<SelectValue placeholder="级别" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -258,8 +266,8 @@ const AlertCenter: React.FC = () => {
|
|||||||
<SelectItem value="info">信息</SelectItem>
|
<SelectItem value="info">信息</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={(v) => handleFilterChange('status', v)}>
|
||||||
<SelectTrigger className="w-[120px]">
|
<SelectTrigger className="w-[100px] h-8 text-xs">
|
||||||
<SelectValue placeholder="状态" />
|
<SelectValue placeholder="状态" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -270,76 +278,85 @@ const AlertCenter: React.FC = () => {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={fetchAlerts}>
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<span>共 {total} 条</span>
|
||||||
刷新
|
<Button variant="outline" size="sm" onClick={fetchAlerts} className="h-7 text-xs px-2">
|
||||||
|
<RefreshCw className="w-3 h-3 mr-1" />刷新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 告警列表 */}
|
{/* 告警表格 — 紧凑 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">告警信息</th>
|
<th className="px-3 py-2 text-left text-xs font-medium">告警信息</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">级别</th>
|
<th className="px-3 py-2 text-left text-xs font-medium w-16">级别</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
|
<th className="px-3 py-2 text-left text-xs font-medium w-16">状态</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">来源</th>
|
<th className="px-3 py-2 text-left text-xs font-medium w-24">来源</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">持续时间</th>
|
<th className="px-3 py-2 text-left text-xs font-medium w-20">持续</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
|
<th className="px-3 py-2 text-left text-xs font-medium w-28">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">加载中...</td></tr>
|
<tr><td colSpan={6} className="px-3 py-6 text-center text-xs text-muted-foreground">加载中...</td></tr>
|
||||||
) : filteredAlerts.length === 0 ? (
|
) : alerts.length === 0 ? (
|
||||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">暂无告警</td></tr>
|
<tr><td colSpan={6} className="px-3 py-6 text-center text-xs text-muted-foreground">暂无告警</td></tr>
|
||||||
) : (
|
) : (
|
||||||
filteredAlerts.map((alert) => (
|
alerts.filter(alert => {
|
||||||
|
// 客户端也保留关键词搜索过滤
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return alert.title.toLowerCase().includes(q) ||
|
||||||
|
alert.description.toLowerCase().includes(q) ||
|
||||||
|
alert.source.toLowerCase().includes(q);
|
||||||
|
}).map((alert) => (
|
||||||
<tr key={alert.id} className="hover:bg-muted/50">
|
<tr key={alert.id} className="hover:bg-muted/50">
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-2">
|
||||||
<div className="font-medium">{alert.title}</div>
|
<div className="font-medium text-xs">{alert.title}</div>
|
||||||
<div className="text-sm text-muted-foreground line-clamp-1">{alert.description}</div>
|
<div className="text-xs text-muted-foreground line-clamp-1">{alert.description}</div>
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="flex gap-1 mt-0.5">
|
||||||
{Object.entries(alert.labels).slice(0, 3).map(([k, v]) => (
|
{Object.entries(alert.labels).slice(0, 2).map(([k, v]) => (
|
||||||
<Badge key={k} variant="outline" className="text-xs">{k}={v}</Badge>
|
<Badge key={k} variant="outline" className="text-[10px] px-1 py-0">{k}={v}</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">{getSeverityBadge(alert.severity)}</td>
|
<td className="px-3 py-2">{getSeverityBadge(alert.severity)}</td>
|
||||||
<td className="px-4 py-3">{getStatusBadge(alert.status)}</td>
|
<td className="px-3 py-2">{getStatusBadge(alert.status)}</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-3 py-2 text-xs">
|
||||||
<div>{alert.source}</div>
|
<div>{alert.source}</div>
|
||||||
<div className="text-muted-foreground">{alert.service}</div>
|
<div className="text-muted-foreground text-[10px]">{alert.service}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
{alert.status === 'resolved' && alert.resolvedAt
|
{alert.status === 'resolved' && alert.resolvedAt
|
||||||
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
|
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分`
|
||||||
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分钟`}
|
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分`}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<Button variant="ghost" size="sm"
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||||
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}>
|
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{alert.status === 'firing' && (
|
{alert.status === 'firing' && (
|
||||||
<Button variant="ghost" size="sm"
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||||
onClick={() => handleAcknowledge(alert.id)}>
|
onClick={() => handleAcknowledge(alert.id)}>
|
||||||
<Check className="w-4 h-4 text-blue-600" />
|
<Check className="w-3.5 h-3.5 text-blue-600" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
|
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
|
||||||
<Button variant="ghost" size="sm"
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||||
onClick={() => handleResolve(alert.id)}>
|
onClick={() => handleResolve(alert.id)}>
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="sm"
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||||
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}>
|
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}>
|
||||||
<VolumeX className="w-4 h-4 text-orange-600" />
|
<VolumeX className="w-3.5 h-3.5 text-orange-600" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -349,6 +366,34 @@ const AlertCenter: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 分页控件 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-t text-xs">
|
||||||
|
<span className="text-muted-foreground">第 {page}/{totalPages} 页,共 {total} 条</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
|
||||||
|
disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||||
|
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
|
||||||
|
const pn = start + i;
|
||||||
|
if (pn > totalPages) return null;
|
||||||
|
return (
|
||||||
|
<Button key={pn} variant={pn === page ? 'default' : 'outline'} size="sm"
|
||||||
|
className="h-7 min-w-[28px] text-xs px-1"
|
||||||
|
onClick={() => setPage(pn)}>
|
||||||
|
{pn}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
|
||||||
|
disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import {
|
import {
|
||||||
Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock,
|
Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock,
|
||||||
Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown,
|
Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown,
|
||||||
ExternalLink
|
ExternalLink, Download, Trash2, Rocket
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -15,12 +15,17 @@ import {
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { dashboardApi, promDatasourceApi, zabbixDatasourceApi } from '@/services/api';
|
import { useUserStore } from '@/store';
|
||||||
|
import { dashboardApi, promDatasourceApi, zabbixDatasourceApi, agentApi, serverApi } from '@/services/api';
|
||||||
import { GrafanaPanelContent } from './GrafanaPanelContent';
|
import { GrafanaPanelContent } from './GrafanaPanelContent';
|
||||||
import { ZabbixDashboard } from './ZabbixDashboard';
|
import { ZabbixDashboard } from './ZabbixDashboard';
|
||||||
import { PrometheusDashboard } from './PrometheusDashboard';
|
import { PrometheusDashboard } from './PrometheusDashboard';
|
||||||
import { http } from '@/services/http';
|
import { http } from '@/services/http';
|
||||||
import { asList } from '@/components/agent/agentUtils';
|
import { asList } from '@/components/agent/agentUtils';
|
||||||
|
import { AgentWizard } from '@/components/agent/AgentWizard';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
// ═══════════ Tab 配置 ═══════════
|
// ═══════════ Tab 配置 ═══════════
|
||||||
type TabId = 'agent' | 'zabbix' | 'prometheus' | 'dashboard';
|
type TabId = 'agent' | 'zabbix' | 'prometheus' | 'dashboard';
|
||||||
@@ -32,16 +37,21 @@ const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// ═══════════ Agent 主机监控表格 ═══════════
|
// ═══════════ Agent 主机监控表格 ═══════════
|
||||||
interface AgentHost { hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; agent_version: string; agent_status: string; os: string; region: string; }
|
interface AgentHost { server_id: number; hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; agent_version: string; agent_status: string; os: string; region: string; }
|
||||||
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
|
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
|
||||||
|
|
||||||
const AgentTab: React.FC = () => {
|
const AgentTab: React.FC = () => {
|
||||||
|
const _confirm = useConfirm();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [hosts, setHosts] = useState<AgentHost[]>([]);
|
const [hosts, setHosts] = useState<AgentHost[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sf, setSf] = useState<SortField>('cpu_usage');
|
const [sf, setSf] = useState<SortField>('cpu_usage');
|
||||||
const [sd, setSd] = useState<'asc' | 'desc'>('desc');
|
const [sd, setSd] = useState<'asc' | 'desc'>('desc');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const [wizardServer, setWizardServer] = useState<any>(null);
|
||||||
const totalPages = Math.max(1, Math.ceil(hosts.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(hosts.length / pageSize));
|
||||||
const fetch = async () => { try { setHosts(asList(await http.get('/agent/latest'))); } catch {} finally { setLoading(false); } };
|
const fetch = async () => { try { setHosts(asList(await http.get('/agent/latest'))); } catch {} finally { setLoading(false); } };
|
||||||
useEffect(() => { fetch(); const t = setInterval(fetch, 30000); return () => clearInterval(t); }, []);
|
useEffect(() => { fetch(); const t = setInterval(fetch, 30000); return () => clearInterval(t); }, []);
|
||||||
@@ -57,28 +67,111 @@ const AgentTab: React.FC = () => {
|
|||||||
const bar = (v: number, l: string) => (
|
const bar = (v: number, l: string) => (
|
||||||
<div className="flex items-center gap-2 min-w-[90px]"><div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden"><div className={`h-full rounded-full transition-all duration-500 ${v >= 80 ? 'bg-orange-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(v, 100)}%` }} /></div><span className={`w-14 text-right text-xs font-mono tabular-nums ${v >= 80 ? 'text-orange-400' : ''}`}>{l}</span></div>
|
<div className="flex items-center gap-2 min-w-[90px]"><div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden"><div className={`h-full rounded-full transition-all duration-500 ${v >= 80 ? 'bg-orange-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(v, 100)}%` }} /></div><span className={`w-14 text-right text-xs font-mono tabular-nums ${v >= 80 ? 'text-orange-400' : ''}`}>{l}</span></div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selAll = selected.size === hosts.length && hosts.length > 0;
|
||||||
|
const selAny = selected.size > 0;
|
||||||
|
const selIds = [...selected].map(Number).filter(n => !isNaN(n));
|
||||||
|
|
||||||
|
// 批量安装 Agent // Logan.Li
|
||||||
|
const [batchInstalling, setBatchInstalling] = useState(false);
|
||||||
|
const handleBatchInstall = async () => {
|
||||||
|
const token = useUserStore.getState().token;
|
||||||
|
if (!token) { toast.error('未登录'); return; }
|
||||||
|
const ok = await _confirm({ title: '批量安装 Agent', description: `确定对选中的 ${selIds.length} 台主机远程安装 Agent?安装将在后台异步执行。`, confirmText: '安装', variant: 'destructive' });
|
||||||
|
if (!ok) return;
|
||||||
|
setBatchInstalling(true);
|
||||||
|
try {
|
||||||
|
const res = await serverApi.batchInstallAgent(selIds, token);
|
||||||
|
toast.success(`已提交:批量安装 ${res?.total ?? selIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
|
||||||
|
setSelected(new Set());
|
||||||
|
setTimeout(() => fetch(), 30000);
|
||||||
|
} catch { toast.error('提交失败'); }
|
||||||
|
finally { setBatchInstalling(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量卸载 Agent // Logan.Li
|
||||||
|
const [batchUninstalling, setBatchUninstalling] = useState(false);
|
||||||
|
const handleBatchUninstall = async () => {
|
||||||
|
const ok = await _confirm({ title: '批量卸载 Agent', description: `确定卸载选中的 ${selIds.length} 台主机的 AIOps Agent?卸载将在后台异步执行。`, confirmText: '卸载', variant: 'destructive' });
|
||||||
|
if (!ok) return;
|
||||||
|
setBatchUninstalling(true);
|
||||||
|
try {
|
||||||
|
await agentApi.batchUninstall(selIds);
|
||||||
|
toast.success(`已提交:批量卸载 ${selIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
|
||||||
|
setSelected(new Set());
|
||||||
|
setTimeout(() => fetch(), 30000);
|
||||||
|
} catch { toast.error('提交失败'); }
|
||||||
|
finally { setBatchUninstalling(false); }
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载中…</p></div>;
|
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载中…</p></div>;
|
||||||
if (!hosts.length) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">暂无 Agent 上报数据</p><p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent</p></div>;
|
if (!hosts.length) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">暂无服务器资产</p><p className="text-xs mt-1">请先在「服务器管理」中添加主机,然后在此安装 Agent</p></div>;
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||||
|
{selAny && <span className="text-xs text-[var(--dg-text-secondary)]">已选 {selected.size} 项</span>}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={() => {
|
||||||
|
const picked = selAny ? hosts.find(h => selected.has(String(h.server_id))) : null;
|
||||||
|
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: (picked as any).has_auth } : null);
|
||||||
|
setWizardOpen(true);
|
||||||
|
}}>
|
||||||
|
<Rocket className="w-3 h-3" />安装 Agent
|
||||||
|
</Button>
|
||||||
|
{selAny && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-blue-500/30 text-blue-400 hover:bg-blue-500/10" onClick={handleBatchInstall} disabled={batchInstalling}>
|
||||||
|
<Rocket className="w-3 h-3" />批量安装 ({selected.size})
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleBatchUninstall} disabled={batchUninstalling}>
|
||||||
|
<Trash2 className="w-3 h-3" />批量卸载 ({selected.size})
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch}><RefreshCw className="w-3 h-3" />刷新</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} server={wizardServer}
|
||||||
|
onInstalled={() => { toast.success('Agent 安装成功'); setWizardOpen(false); fetch(); }}
|
||||||
|
onFailed={() => toast.error('安装失败')} />
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b"><tr>
|
<thead className="bg-[var(--dg-bg-tertiary)] border-b"><tr>
|
||||||
|
<th className="px-2 py-2 w-8">
|
||||||
|
<Checkbox checked={selAll} onCheckedChange={(c) => setSelected(c ? new Set(hosts.map(h => String(h.server_id))) : new Set())} />
|
||||||
|
</th>
|
||||||
{['hostname','agent_version','agent_status','cpu_usage','mem_usage','disk_usage','region','ip'].map(f => (
|
{['hostname','agent_version','agent_status','cpu_usage','mem_usage','disk_usage','region','ip'].map(f => (
|
||||||
<th key={f} className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap" onClick={() => toggle(f as SortField)}>
|
<th key={f} className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap" onClick={() => toggle(f as SortField)}>
|
||||||
<div className="flex items-center gap-1">{{hostname:'主机名',agent_version:'Agent版本',agent_status:'状态',cpu_usage:'CPU',mem_usage:'内存',disk_usage:'磁盘',region:'区域',ip:'IP'}[f]}<SI f={f as SortField} /></div>
|
<div className="flex items-center gap-1">{{hostname:'主机名',agent_version:'Agent版本',agent_status:'状态',cpu_usage:'CPU',mem_usage:'内存',disk_usage:'磁盘',region:'区域',ip:'IP'}[f]}<SI f={f as SortField} /></div>
|
||||||
</th>
|
</th>
|
||||||
))}</tr></thead>
|
))}
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] whitespace-nowrap">操作</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paged.map((h, i) => (
|
{paged.map((h, i) => (
|
||||||
<tr key={h.hostname + i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
|
<tr key={h.hostname + i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||||
|
<td className="px-2 py-2.5">
|
||||||
|
<Checkbox checked={selected.has(String(h.server_id))} onCheckedChange={(c) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
c ? next.add(String(h.server_id)) : next.delete(String(h.server_id));
|
||||||
|
setSelected(next);
|
||||||
|
}} />
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2.5 font-medium text-[var(--dg-text-primary)]">{h.hostname}</td>
|
<td className="px-3 py-2.5 font-medium text-[var(--dg-text-primary)]">{h.hostname}</td>
|
||||||
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.agent_version || '—'}</td>
|
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.agent_version || '—'}</td>
|
||||||
<td className="px-3 py-2.5"><Badge className={`text-[10px] ${h.agent_status==='online'?'bg-emerald-500/20 text-emerald-400':'bg-slate-500/10 text-slate-400'}`}>{h.agent_status==='online'?<><Wifi className="w-3 h-3 mr-1"/>在线</>:<><WifiOff className="w-3 h-3 mr-1"/>离线</>}</Badge></td>
|
<td className="px-3 py-2.5">{h.agent_status==='online'?<Badge className="text-[10px] bg-emerald-500/20 text-emerald-400"><Wifi className="w-3 h-3 mr-1"/>在线</Badge>:h.agent_status==='not_installed'?<Badge className="text-[10px] bg-amber-500/20 text-amber-400">未安装</Badge>:<Badge className="text-[10px] bg-slate-500/10 text-slate-400"><WifiOff className="w-3 h-3 mr-1"/>离线</Badge>}</td>
|
||||||
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage||0), `${Number(h.cpu_usage||0).toFixed(1)}%`)}</td>
|
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage||0), `${Number(h.cpu_usage||0).toFixed(1)}%`)}</td>
|
||||||
<td className="px-3 py-2.5">{bar(Number(h.mem_usage||0), `${Number(h.mem_usage||0).toFixed(1)}%`)}</td>
|
<td className="px-3 py-2.5">{bar(Number(h.mem_usage||0), `${Number(h.mem_usage||0).toFixed(1)}%`)}</td>
|
||||||
<td className="px-3 py-2.5">{bar(Number(h.disk_usage||0), `${Number(h.disk_usage||0).toFixed(1)}%`)}</td>
|
<td className="px-3 py-2.5">{bar(Number(h.disk_usage||0), `${Number(h.disk_usage||0).toFixed(1)}%`)}</td>
|
||||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)]">{h.region||'—'}</td>
|
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)]">{h.region||'—'}</td>
|
||||||
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.ip||'—'}</td>
|
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.ip||'—'}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 px-2"
|
||||||
|
onClick={() => navigate(`/host-monitor?host=${encodeURIComponent(h.hostname)}`)}>
|
||||||
|
<BarChart3 className="w-3.5 h-3.5 mr-1" />图表
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -101,6 +194,7 @@ const AgentTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { regionApi, serverApi } from '@/services/api';
|
import { regionApi, serverApi, credentialApi } from '@/services/api';
|
||||||
|
|
||||||
interface RegionOption {
|
interface RegionOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -94,12 +94,13 @@ function isValidIP(ip: string): boolean {
|
|||||||
return /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && ip.split('.').every((o) => Number(o) >= 0 && Number(o) <= 255);
|
return /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && ip.split('.').every((o) => Number(o) >= 0 && Number(o) <= 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载 CSV 导入模板 // Logan.Li
|
// 下载 CSV 导入模板 — 只需 hostname + ip,端口默认22 账号默认root,Agent 安装后自动补全 // Logan.Li
|
||||||
function downloadTemplate() {
|
function downloadTemplate() {
|
||||||
const csv = [
|
const csv = [
|
||||||
'hostname,ip,port,username,os,cpu,memory,disk,tags',
|
'hostname,ip',
|
||||||
'web-01,192.168.1.10,22,root,CentOS 7,4,8,100,"web,prod"',
|
'web-01,192.168.1.10',
|
||||||
'db-01,192.168.1.20,22,root,Ubuntu 22.04,8,16,200,"db,prod"',
|
'db-01,192.168.1.20',
|
||||||
|
'cache-01,192.168.1.30',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -119,30 +120,36 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
|
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
|
||||||
const [regions, setRegions] = useState<RegionOption[]>([]);
|
const [regions, setRegions] = useState<RegionOption[]>([]);
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
// 凭据选择 // Logan.Li
|
||||||
|
const [credentials, setCredentials] = useState<any[]>([]);
|
||||||
|
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('');
|
||||||
|
|
||||||
// 对话框打开时拉取区域列表 // Logan.Li
|
// 对话框打开时拉取区域列表 + 凭据列表 // Logan.Li
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await regionApi.list();
|
const [regionResp, credResp] = await Promise.all([
|
||||||
|
regionApi.list(),
|
||||||
|
credentialApi.list(),
|
||||||
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const list = resp?.list ?? [];
|
const list = regionResp?.list ?? [];
|
||||||
setRegions(
|
setRegions(list.map((r: any) => ({
|
||||||
list.map((r: any) => ({
|
|
||||||
id: String(r.id ?? ''),
|
id: String(r.id ?? ''),
|
||||||
name: r.name ?? '',
|
name: r.name ?? '',
|
||||||
code: r.code ?? '',
|
code: r.code ?? '',
|
||||||
}))
|
})));
|
||||||
);
|
setCredentials(credResp?.list ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setRegions([]);
|
if (!cancelled) {
|
||||||
|
setRegions([]);
|
||||||
|
setCredentials([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => { cancelled = true; };
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
@@ -239,6 +246,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
servers,
|
servers,
|
||||||
region_id: selectedRegion || undefined,
|
region_id: selectedRegion || undefined,
|
||||||
auth_type: connectionMethod,
|
auth_type: connectionMethod,
|
||||||
|
credential_id: selectedCredentialId ? Number(selectedCredentialId) : undefined,
|
||||||
});
|
});
|
||||||
const r = resp ?? {};
|
const r = resp ?? {};
|
||||||
const success = r.success ?? 0;
|
const success = r.success ?? 0;
|
||||||
@@ -273,7 +281,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl">Excel导入服务器</DialogTitle>
|
<DialogTitle className="text-xl">CSV 批量导入服务器</DialogTitle>
|
||||||
<DialogDescription className="sr-only">通过 CSV 文件批量导入服务器资产</DialogDescription>
|
<DialogDescription className="sr-only">通过 CSV 文件批量导入服务器资产</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -313,7 +321,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-[var(--dg-text-primary)]">主机导入模板</p>
|
<p className="font-medium text-[var(--dg-text-primary)]">主机导入模板</p>
|
||||||
<p className="text-xs text-[var(--dg-text-muted)]">下载Excel模板,填写服务器信息后上传</p>
|
<p className="text-xs text-[var(--dg-text-muted)]">下载 CSV 模板,只填主机名和 IP,端口默认22、账号默认root</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={downloadTemplate}>
|
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={downloadTemplate}>
|
||||||
@@ -341,6 +349,32 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 凭据选择(SSH密钥/密码) — 选中后自动关联到导入的每台服务器 // Logan.Li */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">
|
||||||
|
选择凭据
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)] ml-1">(可选,用于SSH连接)</span>
|
||||||
|
</label>
|
||||||
|
{credentials.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] py-2">
|
||||||
|
暂无凭据,先去「凭证配置」添加 SSH 密钥或密码
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedCredentialId}
|
||||||
|
onChange={(e) => setSelectedCredentialId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
|
||||||
|
>
|
||||||
|
<option value="">不使用凭据(后续手动配置)</option>
|
||||||
|
{credentials.map((c: any) => (
|
||||||
|
<option key={c.id} value={String(c.id)}>
|
||||||
|
{c.type === 'ssh_key' ? '🔑' : c.type === 'api_token' ? '🌐' : '🔒'} {c.name} ({c.type === 'ssh_key' ? 'SSH密钥' : c.type === 'api_token' ? 'API Token' : '密码'})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Connection Method */}
|
{/* Connection Method */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">连接方式</label>
|
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">连接方式</label>
|
||||||
@@ -452,8 +486,6 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">行号</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">行号</th>
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">端口</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">用户名</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -463,8 +495,6 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
<td className="px-3 py-2 text-sm text-[var(--dg-text-muted)]">{row.row}</td>
|
<td className="px-3 py-2 text-sm text-[var(--dg-text-muted)]">{row.row}</td>
|
||||||
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{row.hostname || '-'}</td>
|
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{row.hostname || '-'}</td>
|
||||||
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.ip}</td>
|
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.ip}</td>
|
||||||
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.port}</td>
|
|
||||||
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.username}</td>
|
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{row.status === 'valid' && (
|
{row.status === 'valid' && (
|
||||||
|
|||||||
@@ -37,10 +37,8 @@ import {
|
|||||||
Cloud,
|
Cloud,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Rocket,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Cpu,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { serverApi } from '@/services/api';
|
import { serverApi } from '@/services/api';
|
||||||
import { ServerStatsCards } from './ServerStatsCards';
|
import { ServerStatsCards } from './ServerStatsCards';
|
||||||
@@ -53,7 +51,6 @@ import { ExcelServerImport } from './ExcelServerImport';
|
|||||||
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
||||||
import { getTags } from './serverHelpers';
|
import { getTags } from './serverHelpers';
|
||||||
import { ServerTableRow } from './ServerTableRow';
|
import { ServerTableRow } from './ServerTableRow';
|
||||||
import { AgentWizard } from '@/components/agent/AgentWizard';
|
|
||||||
|
|
||||||
export function ServerManager() {
|
export function ServerManager() {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -74,60 +71,15 @@ export function ServerManager() {
|
|||||||
|
|
||||||
// 终端 Dialog 状态
|
// 终端 Dialog 状态
|
||||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string } | null>(null);
|
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
|
||||||
|
|
||||||
// 分页状态
|
// 分页状态
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
// Agent 安装向导状态
|
|
||||||
const [agentWizardOpen, setAgentWizardOpen] = useState(false);
|
|
||||||
const [wizardServer, setWizardServer] = useState<any>(null);
|
|
||||||
// Agent 安装状态: { [serverId]: 'installing' | 'success' | 'failed' }
|
|
||||||
const [installingIds, setInstallingIds] = useState<Set<number>>(new Set());
|
|
||||||
const [installResults, setInstallResults] = useState<Map<number, { success: boolean; message: string }>>(new Map());
|
|
||||||
|
|
||||||
const openInstallAgent = (server: any) => {
|
|
||||||
setWizardServer(server);
|
|
||||||
setAgentWizardOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBatchInstallAgents = async () => {
|
|
||||||
const token = useUserStore.getState().token;
|
|
||||||
if (!token) { toast.error('请先登录'); return; }
|
|
||||||
const ids = selectedServers.map(id => Number(id));
|
|
||||||
|
|
||||||
setInstallingIds(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
ids.forEach(id => next.add(id));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res: any = await serverApi.batchInstallAgent(ids, token);
|
|
||||||
const results = res?.results || [];
|
|
||||||
setInstallResults(prev => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
results.forEach((r: any) => {
|
|
||||||
next.set(r.server_id, { success: r.installed, message: r.message });
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
toast.success(`批量安装完成: ${res?.success || 0}/${ids.length} 成功`);
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`批量安装失败: ${e?.message || '未知错误'}`);
|
|
||||||
} finally {
|
|
||||||
setInstallingIds(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
ids.forEach(id => next.delete(id));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openTerminal = (server: any) => {
|
const openTerminal = (server: any) => {
|
||||||
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip });
|
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip, hasAuth: server.has_auth ?? true });
|
||||||
setTerminalOpen(true);
|
setTerminalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -444,8 +396,8 @@ export function ServerManager() {
|
|||||||
onClick={() => setExcelImportOpen(true)}
|
onClick={() => setExcelImportOpen(true)}
|
||||||
>
|
>
|
||||||
<FileSpreadsheet className="h-4 w-4 mr-2 text-emerald-400" />
|
<FileSpreadsheet className="h-4 w-4 mr-2 text-emerald-400" />
|
||||||
Excel导入
|
CSV导入
|
||||||
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">批量导入服务器</span>
|
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">hostname + IP 即可</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -493,15 +445,6 @@ export function ServerManager() {
|
|||||||
{selectedServers.length > 0 && (
|
{selectedServers.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span>
|
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleBatchInstallAgents}
|
|
||||||
className="border-[var(--dg-cyan)]/30 text-[var(--dg-cyan)] hover:bg-[var(--dg-cyan)]/10"
|
|
||||||
>
|
|
||||||
<Cpu className="h-4 w-4 mr-2" />
|
|
||||||
批量安装Agent
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -562,9 +505,6 @@ export function ServerManager() {
|
|||||||
onOpenTerminal={openTerminal}
|
onOpenTerminal={openTerminal}
|
||||||
onOpenEdit={openEditForm}
|
onOpenEdit={openEditForm}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onInstallAgent={openInstallAgent}
|
|
||||||
installing={installingIds.has(server.id)}
|
|
||||||
installResult={installResults.get(server.id) || null}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -645,32 +585,6 @@ export function ServerManager() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Agent 安装向导 */}
|
|
||||||
<AgentWizard
|
|
||||||
open={agentWizardOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setAgentWizardOpen(open);
|
|
||||||
if (!open) setWizardServer(null);
|
|
||||||
}}
|
|
||||||
server={wizardServer}
|
|
||||||
onInstalled={(serverId) => {
|
|
||||||
setInstallResults(prev => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(serverId, { success: true, message: 'Agent 已安装' });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
// 刷新列表以更新 agent_status
|
|
||||||
loadData();
|
|
||||||
}}
|
|
||||||
onFailed={(serverId, msg) => {
|
|
||||||
setInstallResults(prev => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(serverId, { success: false, message: msg || '安装失败' });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 终端 Dialog */}
|
{/* 终端 Dialog */}
|
||||||
<TerminalDialog
|
<TerminalDialog
|
||||||
open={terminalOpen}
|
open={terminalOpen}
|
||||||
@@ -678,6 +592,7 @@ export function ServerManager() {
|
|||||||
serverId={terminalServer?.id ?? null}
|
serverId={terminalServer?.id ?? null}
|
||||||
serverName={terminalServer?.name ?? ''}
|
serverName={terminalServer?.name ?? ''}
|
||||||
serverIp={terminalServer?.ip ?? ''}
|
serverIp={terminalServer?.ip ?? ''}
|
||||||
|
hasAuth={terminalServer?.hasAuth ?? true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { TableCell, TableRow } from '@/components/ui/table';
|
import { TableCell, TableRow } from '@/components/ui/table';
|
||||||
import { Cpu, Edit, Loader2, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, CheckCircle, XCircle, Network, HardDrive, Rocket } from 'lucide-react';
|
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive } from 'lucide-react';
|
||||||
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
|
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
|
||||||
|
|
||||||
interface ServerTableRowProps {
|
interface ServerTableRowProps {
|
||||||
@@ -22,9 +22,6 @@ interface ServerTableRowProps {
|
|||||||
onOpenTerminal: (server: any) => void;
|
onOpenTerminal: (server: any) => void;
|
||||||
onOpenEdit: (server: any) => void;
|
onOpenEdit: (server: any) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onInstallAgent: (server: any) => void;
|
|
||||||
installing?: boolean;
|
|
||||||
installResult?: { success: boolean; message: string } | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerTableRow({
|
export function ServerTableRow({
|
||||||
@@ -34,9 +31,6 @@ export function ServerTableRow({
|
|||||||
onOpenTerminal,
|
onOpenTerminal,
|
||||||
onOpenEdit,
|
onOpenEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onInstallAgent,
|
|
||||||
installing = false,
|
|
||||||
installResult = null,
|
|
||||||
}: ServerTableRowProps) {
|
}: ServerTableRowProps) {
|
||||||
const cpuUsage = Number(server.cpu_usage || 0);
|
const cpuUsage = Number(server.cpu_usage || 0);
|
||||||
const memoryUsage = Number(server.memory_usage || 0);
|
const memoryUsage = Number(server.memory_usage || 0);
|
||||||
@@ -151,29 +145,6 @@ export function ServerTableRow({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{/* Agent 安装按钮 — agent_status=online 时隐藏 */}
|
|
||||||
{server.agent_status !== 'online' && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]"
|
|
||||||
onClick={() => onInstallAgent(server)}
|
|
||||||
disabled={installing}
|
|
||||||
title={installResult?.success ? 'Agent 已安装' : installResult ? '安装失败,点击重试' : '安装 Agent'}
|
|
||||||
>
|
|
||||||
{installing ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : installResult ? (
|
|
||||||
installResult.success ? (
|
|
||||||
<CheckCircle className="h-4 w-4 text-emerald-400" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-4 w-4 text-red-400" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Rocket className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)}>
|
||||||
<Terminal className="h-4 w-4" />
|
<Terminal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Terminal as XTerm } from 'xterm';
|
import { Terminal as XTerm } from 'xterm';
|
||||||
import { FitAddon } from 'xterm-addon-fit';
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
import 'xterm/css/xterm.css';
|
import 'xterm/css/xterm.css';
|
||||||
import { Maximize2, Minimize2, X } from 'lucide-react';
|
import { Maximize2, Minimize2, X, Key, Shield } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useUserStore } from '@/store';
|
import { useUserStore } from '@/store';
|
||||||
|
import { credentialApi, serverCredentialApi } from '@/services/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface TerminalDialogProps {
|
interface TerminalDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -18,6 +21,8 @@ interface TerminalDialogProps {
|
|||||||
serverId: number | null;
|
serverId: number | null;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
serverIp: string;
|
serverIp: string;
|
||||||
|
/** 服务器是否有可用的认证凭据(密码或SSH密钥) */
|
||||||
|
hasAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +35,7 @@ export function TerminalDialog({
|
|||||||
serverId,
|
serverId,
|
||||||
serverName,
|
serverName,
|
||||||
serverIp,
|
serverIp,
|
||||||
|
hasAuth = true,
|
||||||
}: TerminalDialogProps) {
|
}: TerminalDialogProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<XTerm | null>(null);
|
const termRef = useRef<XTerm | null>(null);
|
||||||
@@ -38,9 +44,44 @@ export function TerminalDialog({
|
|||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
// 连接终端
|
// 凭据选择状态(hasAuth=false 时显示) // Logan.Li
|
||||||
|
const [credentials, setCredentials] = useState<any[]>([]);
|
||||||
|
const [selectedCredId, setSelectedCredId] = useState<string>('');
|
||||||
|
const [assigning, setAssigning] = useState(false);
|
||||||
|
const [authReady, setAuthReady] = useState(hasAuth); // 认证就绪标志
|
||||||
|
|
||||||
|
// 加载凭据列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !serverId) return;
|
if (!open || hasAuth) return;
|
||||||
|
credentialApi.list().then((res: any) => setCredentials(res?.list || [])).catch(() => {});
|
||||||
|
}, [open, hasAuth]);
|
||||||
|
|
||||||
|
// 关联凭据
|
||||||
|
const handleAssignCredential = async () => {
|
||||||
|
if (!selectedCredId || !serverId) return;
|
||||||
|
try {
|
||||||
|
setAssigning(true);
|
||||||
|
await serverCredentialApi.assign(serverId, Number(selectedCredId));
|
||||||
|
toast.success('凭据已关联');
|
||||||
|
setAuthReady(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message || '关联失败');
|
||||||
|
} finally {
|
||||||
|
setAssigning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setAuthReady(hasAuth);
|
||||||
|
setSelectedCredId('');
|
||||||
|
}
|
||||||
|
}, [open, hasAuth]);
|
||||||
|
|
||||||
|
// 连接终端(authReady=true 时才执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !serverId || !authReady) return;
|
||||||
|
|
||||||
// 创建终端
|
// 创建终端
|
||||||
const term = new XTerm({
|
const term = new XTerm({
|
||||||
@@ -115,7 +156,7 @@ export function TerminalDialog({
|
|||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
};
|
};
|
||||||
}, [open, serverId]);
|
}, [open, serverId, authReady]);
|
||||||
|
|
||||||
// 用 ResizeObserver 监听容器实际尺寸变化,自动 fit 终端
|
// 用 ResizeObserver 监听容器实际尺寸变化,自动 fit 终端
|
||||||
// 这比 window resize 或 setTimeout 更可靠:
|
// 这比 window resize 或 setTimeout 更可靠:
|
||||||
@@ -189,12 +230,47 @@ export function TerminalDialog({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 凭据选择器(无认证凭据时显示) */}
|
||||||
|
{!authReady && (
|
||||||
|
<div className="p-6 flex flex-col items-center justify-center gap-4" style={{ height: '480px' }}>
|
||||||
|
<Shield className="h-12 w-12 text-amber-400" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-[var(--dg-text-primary)] font-medium mb-1">服务器未配置认证凭据</p>
|
||||||
|
<p className="text-sm text-[var(--dg-text-muted)]">请选择一个凭据用于 SSH 连接</p>
|
||||||
|
</div>
|
||||||
|
{credentials.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)]">暂无可用凭据,请先去「凭证配置」添加</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedCredId}
|
||||||
|
onChange={(e) => setSelectedCredId(e.target.value)}
|
||||||
|
className="w-64 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
|
||||||
|
>
|
||||||
|
<option value="">-- 选择凭据 --</option>
|
||||||
|
{credentials.map((c: any) => (
|
||||||
|
<option key={c.id} value={String(c.id)}>
|
||||||
|
{c.type === 'ssh_key' ? '🔑' : '🔒'} {c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleAssignCredential}
|
||||||
|
disabled={!selectedCredId || assigning}
|
||||||
|
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||||
|
>
|
||||||
|
{assigning ? '关联中...' : '确认连接'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 终端容器 */}
|
{/* 终端容器 */}
|
||||||
|
{authReady && (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="bg-[#0a0e14]"
|
className="bg-[#0a0e14]"
|
||||||
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Plus, Trash2, Edit3, Key, Globe, Shield, Eye, EyeOff, RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { credentialApi } from '@/services/api';
|
||||||
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
type CredentialType = 'ssh_key' | 'api_token' | 'password' | 'access_key';
|
||||||
|
|
||||||
|
const TYPE_CONFIG: Record<CredentialType, { label: string; icon: typeof Key; color: string }> = {
|
||||||
|
ssh_key: { label: 'SSH 密钥', icon: Key, color: 'text-amber-400' },
|
||||||
|
api_token: { label: 'API Token', icon: Globe, color: 'text-blue-400' },
|
||||||
|
password: { label: '密码', icon: Shield, color: 'text-emerald-400' },
|
||||||
|
access_key: { label: 'AK密钥对', icon: Key, color: 'text-purple-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CredentialItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: CredentialType;
|
||||||
|
preview: string;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM = { name: '', type: 'password' as CredentialType, content: '', description: '' };
|
||||||
|
|
||||||
|
export function CredentialManager() {
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [credentials, setCredentials] = useState<CredentialItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 表单状态
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [formData, setFormData] = useState(EMPTY_FORM);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showContent, setShowContent] = useState(false);
|
||||||
|
|
||||||
|
const fetchList = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res: any = await credentialApi.list();
|
||||||
|
setCredentials(res?.list || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载凭据列表失败:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchList(); }, [fetchList]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData(EMPTY_FORM);
|
||||||
|
setShowContent(false);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: CredentialItem) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setFormData({
|
||||||
|
name: item.name,
|
||||||
|
type: item.type,
|
||||||
|
content: '', // 编辑时空内容表示不更新
|
||||||
|
description: item.description || '',
|
||||||
|
});
|
||||||
|
setShowContent(false);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.name || (!editingId && !formData.content)) return;
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
if (editingId) {
|
||||||
|
await credentialApi.update(editingId, {
|
||||||
|
name: formData.name,
|
||||||
|
type: formData.type,
|
||||||
|
...(formData.content ? { content: formData.content } : {}),
|
||||||
|
description: formData.description,
|
||||||
|
});
|
||||||
|
toast.success('凭据已更新');
|
||||||
|
} else {
|
||||||
|
await credentialApi.create(formData);
|
||||||
|
toast.success('凭据已创建');
|
||||||
|
}
|
||||||
|
setFormOpen(false);
|
||||||
|
fetchList();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message || '操作失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
const item = credentials.find(c => c.id === id);
|
||||||
|
const ok = await confirm({
|
||||||
|
title: '删除凭据',
|
||||||
|
description: `确定删除「${item?.name || id}」吗?删除后无法恢复,关联的服务器将无法使用此凭据连接。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await credentialApi.delete(id);
|
||||||
|
toast.success('凭据已删除');
|
||||||
|
fetchList();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcon = (type: CredentialType) => {
|
||||||
|
const cfg = TYPE_CONFIG[type];
|
||||||
|
const Icon = cfg.icon;
|
||||||
|
return <Icon className={`h-4 w-4 ${cfg.color}`} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeBadge = (type: CredentialType) => {
|
||||||
|
const cfg = TYPE_CONFIG[type];
|
||||||
|
const bgMap: Record<string, string> = {
|
||||||
|
ssh_key: 'bg-amber-500/20 text-amber-400',
|
||||||
|
api_token: 'bg-blue-500/20 text-blue-400',
|
||||||
|
password: 'bg-emerald-500/20 text-emerald-400',
|
||||||
|
access_key: 'bg-purple-500/20 text-purple-400',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge className={`border-0 text-xs ${bgMap[type] || ''}`}>
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">凭证配置</h1>
|
||||||
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">
|
||||||
|
SSH 密钥、API Token、密码、AK密钥对等敏感信息统一加密管理
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
|
||||||
|
onClick={fetchList}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white" onClick={openCreate}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
添加凭据
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||||
|
<TableHead className="text-[var(--dg-text-secondary)]">名称</TableHead>
|
||||||
|
<TableHead className="text-[var(--dg-text-secondary)]">类型</TableHead>
|
||||||
|
<TableHead className="text-[var(--dg-text-secondary)]">内容预览</TableHead>
|
||||||
|
<TableHead className="text-[var(--dg-text-secondary)]">描述</TableHead>
|
||||||
|
<TableHead className="text-[var(--dg-text-secondary)]">更新时间</TableHead>
|
||||||
|
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow className="border-[var(--dg-border)]">
|
||||||
|
<TableCell colSpan={6} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||||
|
加载中...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : credentials.length === 0 ? (
|
||||||
|
<TableRow className="border-[var(--dg-border)]">
|
||||||
|
<TableCell colSpan={6} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||||
|
暂无凭据,点击「添加凭据」创建
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
credentials.map((cred) => (
|
||||||
|
<TableRow key={cred.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||||
|
<TableCell className="font-medium text-[var(--dg-text-primary)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{typeIcon(cred.type)}
|
||||||
|
{cred.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{typeBadge(cred.type)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs text-[var(--dg-text-muted)] bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">
|
||||||
|
{cred.preview || '****'}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-[var(--dg-text-secondary)] max-w-[200px] truncate" title={cred.description}>
|
||||||
|
{cred.description || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||||
|
{cred.updated_at ? new Date(cred.updated_at).toLocaleDateString('zh-CN') : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||||
|
onClick={() => openEdit(cred)}
|
||||||
|
>
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-red-400"
|
||||||
|
onClick={() => handleDelete(cred.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Form Dialog */}
|
||||||
|
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||||
|
<DialogContent className="max-w-lg bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId ? '编辑凭据' : '添加凭据'}</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{editingId ? '编辑凭据信息,留空内容不更新' : '添加新的凭据'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 名称 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-1">
|
||||||
|
名称 <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="如: 生产环境SSH密钥"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 类型 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-1">类型</label>
|
||||||
|
<Select
|
||||||
|
value={formData.type}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, type: v as CredentialType })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||||
|
<SelectItem value="ssh_key">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Key className="h-4 w-4 text-amber-400" /> SSH 密钥
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="api_token">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4 text-blue-400" /> API Token
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="password">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-emerald-400" /> 密码
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="access_key">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Key className="h-4 w-4 text-purple-400" /> AK密钥对
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-1">
|
||||||
|
{formData.type === 'ssh_key' ? 'SSH 私钥' : formData.type === 'api_token' ? 'Token' : formData.type === 'access_key' ? 'AK/SK (每行一对,用空格或逗号分隔)' : '密码'}
|
||||||
|
{!editingId && <span className="text-red-400"> *</span>}
|
||||||
|
{editingId && <span className="text-xs text-[var(--dg-text-muted)] ml-1">(留空不更新)</span>}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
placeholder={
|
||||||
|
formData.type === 'ssh_key'
|
||||||
|
? '-----BEGIN OPENSSH PRIVATE KEY-----\n...'
|
||||||
|
: formData.type === 'api_token'
|
||||||
|
? '***'
|
||||||
|
: formData.type === 'access_key'
|
||||||
|
? 'AKID*** SK***\nAKID*** SK***'
|
||||||
|
: '输入密码'
|
||||||
|
}
|
||||||
|
value={formData.content}
|
||||||
|
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||||
|
className={`bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] min-h-[100px] ${formData.type === 'ssh_key' ? 'font-mono text-xs' : showContent ? '' : 'text-security-disc'}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2 h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||||
|
onClick={() => setShowContent(!showContent)}
|
||||||
|
title={showContent ? '隐藏' : '显示'}
|
||||||
|
>
|
||||||
|
{showContent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-1">描述</label>
|
||||||
|
<Input
|
||||||
|
placeholder="可选备注"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 安全提示 */}
|
||||||
|
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<p className="text-xs text-amber-400 flex items-center gap-2">
|
||||||
|
<Shield className="h-3.5 w-3.5" />
|
||||||
|
凭据内容使用 AES-256-GCM 加密存储,仅在服务器端解密使用
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" className="border-[var(--dg-border)]" onClick={() => setFormOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !formData.name}
|
||||||
|
>
|
||||||
|
{saving ? '保存中...' : editingId ? '保存' : '创建'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+386
-376
@@ -1,454 +1,464 @@
|
|||||||
// 主机监控 — Agent 采集指标展示 (CPU/内存/TCP/磁盘IO/趋势图) // Logan.Li
|
// 主机监控详情 — 阿里云云监控风格 (分类指标 + 时间范围 + 时序图表)
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { agentApi } from '@/services/api';
|
import { agentApi } from '@/services/api';
|
||||||
import { useUserStore } from '@/store';
|
|
||||||
import { asList, isOnline, timeAgo } from '@/components/agent/agentUtils';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Cpu, HardDrive, Network, Activity, Server, Loader2, TrendingUp } from 'lucide-react';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
Cpu, HardDrive, Network, Activity, Server, Loader2,
|
||||||
Area,
|
Info, Monitor, Disc, Zap, Clock
|
||||||
XAxis,
|
} from 'lucide-react';
|
||||||
YAxis,
|
import {
|
||||||
CartesianGrid,
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line,
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
// Tailwind 不支持动态 class,用颜色映射表
|
// 时间范围配置
|
||||||
const colorMap: Record<string, string> = {
|
const TIME_RANGES = [
|
||||||
blue: 'bg-blue-500/10',
|
{ label: '1小时', hours: 1 },
|
||||||
emerald: 'bg-emerald-500/10',
|
{ label: '6小时', hours: 6 },
|
||||||
purple: 'bg-purple-500/10',
|
{ label: '12小时', hours: 12 },
|
||||||
orange: 'bg-orange-500/10',
|
{ label: '1天', hours: 24 },
|
||||||
};
|
{ label: '3天', hours: 72 },
|
||||||
|
{ label: '7天', hours: 168 },
|
||||||
|
{ label: '14天', hours: 336 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
function parseJSON(raw: string): Record<string, any> {
|
// 聚合:把原始数据压缩到 targetBuckets 个桶
|
||||||
|
function bucketData(data: number[], targetBuckets: number = 48): { time: string; value: number }[] {
|
||||||
|
if (!data || data.length === 0) return [];
|
||||||
|
const perBucket = Math.max(1, Math.ceil(data.length / targetBuckets));
|
||||||
|
const result: { time: string; value: number }[] = [];
|
||||||
|
for (let i = 0; i < data.length; i += perBucket) {
|
||||||
|
const slice = data.slice(i, i + perBucket);
|
||||||
|
const avg = slice.reduce((a, b) => a + b, 0) / slice.length;
|
||||||
|
result.push({ time: `-${Math.round((data.length - i) * 10 / 3600)}h`, value: Number(avg.toFixed(1)) });
|
||||||
|
}
|
||||||
|
return result.slice(-targetBuckets);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSON(raw: any): any {
|
||||||
if (!raw) return {};
|
if (!raw) return {};
|
||||||
|
if (typeof raw === 'object') return raw;
|
||||||
try { return JSON.parse(raw); } catch { return {}; }
|
try { return JSON.parse(raw); } catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtBps(bps: number): string {
|
function formatBPS(bps: number): string {
|
||||||
if (!bps || bps === 0) return '--';
|
if (bps >= 1024 * 1024 * 1024) return `${(bps / 1024 / 1024 / 1024).toFixed(1)} GB/s`;
|
||||||
if (bps > 1e9) return `${(bps / 1e9).toFixed(1)} GB/s`;
|
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
|
||||||
if (bps > 1e6) return `${(bps / 1e6).toFixed(1)} MB/s`;
|
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
|
||||||
if (bps > 1e3) return `${(bps / 1e3).toFixed(1)} KB/s`;
|
|
||||||
return `${bps.toFixed(1)} B/s`;
|
return `${bps.toFixed(1)} B/s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtNum(n: number): string {
|
|
||||||
return n != null && !Number.isNaN(n) ? String(Math.round(n)) : '--';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HostMonitor() {
|
export function HostMonitor() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const urlHost = searchParams.get('host') || '';
|
const urlHost = searchParams.get('host') || '';
|
||||||
const [hosts, setHosts] = useState<any[]>([]);
|
const [hosts, setHosts] = useState<any[]>([]);
|
||||||
const [selectedHost, setSelectedHost] = useState(urlHost);
|
const [selectedHost, setSelectedHost] = useState(urlHost);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState('basic');
|
||||||
|
const [rangeHours, setRangeHours] = useState(1);
|
||||||
|
|
||||||
|
// 原始历史数据
|
||||||
|
const [history, setHistory] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// 最新快照
|
||||||
const [latest, setLatest] = useState<any>(null);
|
const [latest, setLatest] = useState<any>(null);
|
||||||
const [cpuKlines, setCpuKlines] = useState<any[]>([]);
|
// 服务器信息(来自 asset_servers):OS、内核、总内存等,不与 history 混淆
|
||||||
const [memKlines, setMemKlines] = useState<any[]>([]);
|
const [serverInfo, setServerInfo] = useState<any>(null);
|
||||||
const [cardLoading, setCardLoading] = useState(true);
|
|
||||||
const [chartLoading, setChartLoading] = useState(false);
|
|
||||||
|
|
||||||
// Top 5 排行
|
|
||||||
const [topCPU, setTopCPU] = useState<any[]>([]);
|
|
||||||
const [topMem, setTopMem] = useState<any[]>([]);
|
|
||||||
const [topDisk, setTopDisk] = useState<any[]>([]);
|
|
||||||
|
|
||||||
|
// 加载主机列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import('@/services/http').then(async ({ http }) => {
|
agentApi.getLatest().then((data: any) => {
|
||||||
try {
|
const list = Array.isArray(data) ? data : (data?.data || data?.list || []);
|
||||||
const [cpu, mem, disk] = await Promise.all([
|
|
||||||
http.get('/metrics/top?metric=cpu&limit=5'),
|
|
||||||
http.get('/metrics/top?metric=memory&limit=5'),
|
|
||||||
http.get('/metrics/top?metric=disk&limit=5'),
|
|
||||||
]);
|
|
||||||
setTopCPU(Array.isArray(cpu) ? cpu : []);
|
|
||||||
setTopMem(Array.isArray(mem) ? mem : []);
|
|
||||||
setTopDisk(Array.isArray(disk) ? disk : []);
|
|
||||||
} catch { /* 后端未就绪时静默 */ }
|
|
||||||
});
|
|
||||||
}, [hosts]);
|
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// Step 1: 加载主机列表
|
|
||||||
const loadHosts = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await agentApi.getLatest();
|
|
||||||
const list = asList(data);
|
|
||||||
setHosts(list);
|
setHosts(list);
|
||||||
if (list.length > 0 && !selectedHost) {
|
if (list.length > 0 && !selectedHost) {
|
||||||
// 优先匹配 URL 参数传入的主机
|
const m = urlHost ? list.find((h: any) => h.hostname === urlHost) : null;
|
||||||
const matched = urlHost ? list.find((h: any) => h.hostname === urlHost) : null;
|
setSelectedHost(m?.hostname || list[0]?.hostname || '');
|
||||||
setSelectedHost(matched ? matched.hostname : list[0].hostname);
|
|
||||||
}
|
}
|
||||||
} catch { /* 后端不可用时显示空状态 */ }
|
const cur = list.find((h: any) => h.hostname === (urlHost || selectedHost));
|
||||||
finally { setCardLoading(false); }
|
if (cur) {
|
||||||
}, [selectedHost, urlHost]);
|
setLatest(cur);
|
||||||
|
setServerInfo(cur);
|
||||||
|
}
|
||||||
|
}).catch(() => {}).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { loadHosts(); }, []);
|
// 加载历史数据
|
||||||
|
|
||||||
// Step 2: 选中主机后加载 K 线
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedHost) return;
|
if (!selectedHost) return;
|
||||||
setChartLoading(true);
|
setLoading(true);
|
||||||
|
agentApi.getHistory(selectedHost, rangeHours)
|
||||||
const now = new Date();
|
.then((data: any) => {
|
||||||
const from = new Date(now.getTime() - 60 * 60 * 1000);
|
const list = Array.isArray(data) ? data : (data?.data || data?.list || []);
|
||||||
// JS toISOString() 包含毫秒 (2026-06-18T12:50:00.000Z),Go time.RFC3339 不解析毫秒
|
setHistory(list);
|
||||||
const toISO = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
if (list.length > 0) {
|
||||||
|
const fresh = list[list.length - 1];
|
||||||
Promise.all([
|
setLatest((prev: any) => ({ ...prev, ...fresh }));
|
||||||
agentApi.getKline({ hostname: selectedHost, metric: 'cpu', interval: '5m', from: toISO(from), to: toISO(now) }),
|
|
||||||
agentApi.getKline({ hostname: selectedHost, metric: 'mem', interval: '5m', from: toISO(from), to: toISO(now) }),
|
|
||||||
])
|
|
||||||
.then(([cpu, mem]) => {
|
|
||||||
setCpuKlines(asList(cpu));
|
|
||||||
setMemKlines(asList(mem));
|
|
||||||
})
|
|
||||||
.catch(() => { setCpuKlines([]); setMemKlines([]); })
|
|
||||||
.finally(() => setChartLoading(false));
|
|
||||||
|
|
||||||
// 从 hosts 列表中同步 selected host 的最新指标
|
|
||||||
const current = hosts.find((h: any) => h.hostname === selectedHost);
|
|
||||||
if (current) setLatest(current);
|
|
||||||
}, [selectedHost, hosts]);
|
|
||||||
|
|
||||||
// Step 3: WebSocket 实时推送
|
|
||||||
useEffect(() => {
|
|
||||||
const token = useUserStore.getState().token;
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/metrics?token=${token}`;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const connect = () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.type === 'metrics_update' && msg.hostname === selectedHost) {
|
|
||||||
setLatest((prev: any) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
const netInfo = parseJSON(prev.net_info);
|
|
||||||
const diskIO = parseJSON(prev.disk_io_info);
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
cpu_usage: msg.cpu_usage ?? prev.cpu_usage,
|
|
||||||
mem_usage: msg.memory_usage ?? prev.mem_usage,
|
|
||||||
disk_info: prev.disk_info, // 保留原有磁盘信息
|
|
||||||
net_info: JSON.stringify({
|
|
||||||
...netInfo,
|
|
||||||
tcp_established: msg.tcp_established ?? netInfo.tcp_established,
|
|
||||||
tcp_time_wait: msg.tcp_time_wait ?? netInfo.tcp_time_wait,
|
|
||||||
net_rx_bps: msg.net_rx_bps ?? netInfo.net_rx_bps,
|
|
||||||
net_tx_bps: msg.net_tx_bps ?? netInfo.net_tx_bps,
|
|
||||||
}),
|
|
||||||
disk_io_info: JSON.stringify({
|
|
||||||
...diskIO,
|
|
||||||
read_bps: msg.disk_read_bps ?? diskIO.read_bps,
|
|
||||||
write_bps: msg.disk_write_bps ?? diskIO.write_bps,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch { /* WS 消息解析失败静默 */ }
|
})
|
||||||
};
|
.catch(() => setHistory([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [selectedHost, rangeHours]);
|
||||||
|
|
||||||
ws.onclose = () => {
|
// 提取指标数组
|
||||||
if (!cancelled) reconnectRef.current = setTimeout(connect, 3000);
|
const metrics = useMemo(() => {
|
||||||
};
|
const cpu: number[] = [];
|
||||||
};
|
const mem: number[] = [];
|
||||||
|
const load1: number[] = [];
|
||||||
|
const disk: number[] = [];
|
||||||
|
const tcpEst: number[] = [];
|
||||||
|
const rx: number[] = [];
|
||||||
|
const tx: number[] = [];
|
||||||
|
const ioRead: number[] = [];
|
||||||
|
const ioWrite: number[] = [];
|
||||||
|
const ioReadOps: number[] = [];
|
||||||
|
const ioWriteOps: number[] = [];
|
||||||
|
|
||||||
connect();
|
for (const r of history) {
|
||||||
return () => {
|
cpu.push(r.cpu_usage || 0);
|
||||||
cancelled = true;
|
mem.push(r.mem_usage || 0);
|
||||||
if (reconnectRef.current) clearTimeout(reconnectRef.current);
|
load1.push(r.load1 || 0);
|
||||||
wsRef.current?.close();
|
disk.push(r.disk_usage || 0);
|
||||||
|
|
||||||
|
const net = parseJSON(r.net_info);
|
||||||
|
tcpEst.push(net.tcp_established || 0);
|
||||||
|
rx.push(net.net_rx_bps || 0);
|
||||||
|
tx.push(net.net_tx_bps || 0);
|
||||||
|
|
||||||
|
const dio = parseJSON(r.disk_io_info);
|
||||||
|
ioRead.push(dio.read_bps || 0);
|
||||||
|
ioWrite.push(dio.write_bps || 0);
|
||||||
|
ioReadOps.push(dio.read_iops || 0);
|
||||||
|
ioWriteOps.push(dio.write_iops || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = rangeHours <= 1 ? 60 : rangeHours <= 6 ? 72 : rangeHours <= 24 ? 96 : 120;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu: bucketData(cpu, target),
|
||||||
|
mem: bucketData(mem, target),
|
||||||
|
load1: bucketData(load1, target),
|
||||||
|
disk: bucketData(disk, target),
|
||||||
|
tcpEst: bucketData(tcpEst, target),
|
||||||
|
rx: bucketData(rx.map(v => v / 1024), target), // KB/s
|
||||||
|
tx: bucketData(tx.map(v => v / 1024), target),
|
||||||
|
ioRead: bucketData(ioRead.map(v => v / 1024), target), // KB/s
|
||||||
|
ioWrite: bucketData(ioWrite.map(v => v / 1024), target), // KB/s
|
||||||
|
ioReadOps: bucketData(ioReadOps, target), // count/s
|
||||||
|
ioWriteOps: bucketData(ioWriteOps, target), // count/s
|
||||||
};
|
};
|
||||||
}, [selectedHost]);
|
}, [history, rangeHours]);
|
||||||
|
|
||||||
// 解析最新 JSON 字段
|
|
||||||
const netInfo = parseJSON(latest?.net_info);
|
const netInfo = parseJSON(latest?.net_info);
|
||||||
|
const portInfo: any[] = Array.isArray(latest?.port_info) ? latest.port_info : (Array.isArray(parseJSON(latest?.port_info)) ? parseJSON(latest?.port_info) : []);
|
||||||
const diskIOInfo = parseJSON(latest?.disk_io_info);
|
const diskIOInfo = parseJSON(latest?.disk_io_info);
|
||||||
|
|
||||||
// 合并 K 线为图表数据
|
const currentVal = (key: string, fallback = '--') => {
|
||||||
const chartData = cpuKlines.map((k: any, i: number) => ({
|
if (!latest) return fallback;
|
||||||
time: new Date(k.bucket_time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
if (key === 'cpu') return latest.cpu_usage != null ? `${latest.cpu_usage.toFixed(1)}%` : fallback;
|
||||||
cpu: k.avg ?? k.close ?? 0,
|
if (key === 'mem') return latest.mem_usage != null ? `${latest.mem_usage.toFixed(1)}%` : fallback;
|
||||||
mem: memKlines[i]?.avg ?? memKlines[i]?.close ?? 0,
|
if (key === 'disk') return latest.disk_usage != null ? `${latest.disk_usage.toFixed(1)}%` : fallback;
|
||||||
}));
|
if (key === 'load1') return latest.load1 != null ? String(latest.load1) : fallback;
|
||||||
|
if (key === 'tcp') return netInfo.tcp_established != null ? String(netInfo.tcp_established) : fallback;
|
||||||
|
if (key === 'rx') return netInfo.net_rx_bps != null ? `${(netInfo.net_rx_bps / 1024).toFixed(1)} KB/s` : fallback;
|
||||||
|
if (key === 'tx') return netInfo.net_tx_bps != null ? `${(netInfo.net_tx_bps / 1024).toFixed(1)} KB/s` : fallback;
|
||||||
|
if (key === 'ioR') return diskIOInfo.read_bps != null ? `${formatBPS(diskIOInfo.read_bps)}` : fallback;
|
||||||
|
if (key === 'ioW') return diskIOInfo.write_bps != null ? `${formatBPS(diskIOInfo.write_bps)}` : fallback;
|
||||||
|
if (key === 'ioR_ops') return diskIOInfo.read_iops != null ? `${diskIOInfo.read_iops.toFixed(1)} count/s` : fallback;
|
||||||
|
if (key === 'ioW_ops') return diskIOInfo.write_iops != null ? `${diskIOInfo.write_iops.toFixed(1)} count/s` : fallback;
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !history.length) {
|
||||||
|
return <div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-slate-500" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-3">
|
||||||
{/* 主机选择器 */}
|
{/* 顶栏:主机选择 + 时间范围 */}
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<label className="text-sm text-[var(--dg-text-muted)] shrink-0">选择主机:</label>
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="h-4 w-4 text-blue-400" />
|
||||||
<Select value={selectedHost} onValueChange={setSelectedHost}>
|
<Select value={selectedHost} onValueChange={setSelectedHost}>
|
||||||
<SelectTrigger className="w-64 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
<SelectTrigger className="w-52 h-8 text-xs bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
<SelectValue placeholder={cardLoading ? '加载中...' : '选择主机...'} />
|
<SelectValue placeholder="选择主机..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||||
{hosts.map((h: any) => (
|
{hosts.map((h: any) => (
|
||||||
<SelectItem key={h.hostname} value={h.hostname}>
|
<SelectItem key={h.hostname} value={h.hostname}>{h.hostname}</SelectItem>
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
{h.hostname}
|
|
||||||
<span
|
|
||||||
className={`inline-block w-1.5 h-1.5 rounded-full ${
|
|
||||||
isOnline(h.report_time) ? 'bg-emerald-400' : 'bg-red-400'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{latest && (
|
{serverInfo && (
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
<span className="text-xs text-[var(--dg-text-muted)] ml-2">
|
||||||
最后上报: {timeAgo(latest.report_time)}
|
{serverInfo.os || ''} | {serverInfo.cpu_cores || '?'} 核 | {serverInfo.mem_total ? `${serverInfo.mem_total.toFixed(0)}GB` : '?'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top 5 排行 */}
|
{/* 时间范围 */}
|
||||||
{(topCPU.length > 0 || topMem.length > 0 || topDisk.length > 0) && (
|
<div className="flex items-center gap-1">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
{TIME_RANGES.map(tr => (
|
||||||
<TopRankCard title="🔥 CPU 使用率 Top 5" icon={<Cpu className="h-4 w-4 text-red-400" />} data={topCPU} valueKey="cpu_usage" unit="%" color="red" />
|
<Button
|
||||||
<TopRankCard title="💾 内存使用率 Top 5" icon={<Server className="h-4 w-4 text-amber-400" />} data={topMem} valueKey="memory_usage" unit="%" color="amber" />
|
key={tr.hours}
|
||||||
<TopRankCard title="💿 磁盘使用率 Top 5" icon={<HardDrive className="h-4 w-4 text-orange-400" />} data={topDisk} valueKey="disk_usage" unit="%" color="orange" />
|
variant={rangeHours === tr.hours ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs px-2.5"
|
||||||
|
onClick={() => setRangeHours(tr.hours)}
|
||||||
|
>
|
||||||
|
{tr.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Badge variant="outline" className="text-[10px] h-6">周期: 15秒</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 概览卡片 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<MetricCard
|
|
||||||
icon={<Cpu className="h-5 w-5 text-blue-400" />}
|
|
||||||
label="CPU 使用率"
|
|
||||||
value={latest?.cpu_usage != null ? `${latest.cpu_usage.toFixed(1)}%` : '--'}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
icon={<Server className="h-5 w-5 text-emerald-400" />}
|
|
||||||
label="内存使用率"
|
|
||||||
value={latest?.mem_usage != null ? `${latest.mem_usage.toFixed(1)}%` : '--'}
|
|
||||||
color="emerald"
|
|
||||||
detail={latest?.mem_total ? `${(latest.mem_used ?? 0).toFixed(1)} / ${latest.mem_total.toFixed(0)} GB` : undefined}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
icon={<Network className="h-5 w-5 text-purple-400" />}
|
|
||||||
label="TCP 连接数"
|
|
||||||
value={fmtNum(netInfo.tcp_established)}
|
|
||||||
color="purple"
|
|
||||||
detail={netInfo.tcp_time_wait != null ? `TIME_WAIT ${netInfo.tcp_time_wait}` : undefined}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
icon={<HardDrive className="h-5 w-5 text-orange-400" />}
|
|
||||||
label="磁盘 IO"
|
|
||||||
value={fmtBps((diskIOInfo.read_bps ?? 0) + (diskIOInfo.write_bps ?? 0))}
|
|
||||||
color="orange"
|
|
||||||
detail={diskIOInfo.read_iops != null ? `IOPS R${Math.round(diskIOInfo.read_iops)}/W${Math.round(diskIOInfo.write_iops ?? 0)}` : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 趋势图 + 网络/磁盘 IO 面板 */}
|
{/* 分类 Tab */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
{/* CPU / 内存趋势 */}
|
<TabsList className="h-8 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] gap-0">
|
||||||
|
{[
|
||||||
|
{ id: 'basic', label: '基础监控', icon: <Monitor className="w-3 h-3" />, desc: 'CPU/内存/负载' },
|
||||||
|
{ id: 'disk', label: '磁盘监控', icon: <HardDrive className="w-3 h-3" />, desc: '使用率/IO' },
|
||||||
|
{ id: 'network', label: '网络监控', icon: <Network className="w-3 h-3" />, desc: 'TCP/流量' },
|
||||||
|
{ id: 'system', label: '系统信息', icon: <Info className="w-3 h-3" />, desc: 'OS/内核/运行时间' },
|
||||||
|
].map(tab => (
|
||||||
|
<TabsTrigger key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1 data-[state=active]:bg-[var(--dg-bg-primary)]">
|
||||||
|
{tab.icon}{tab.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ═══ 基础监控: CPU / 内存 / 负载 ═══ */}
|
||||||
|
<TabsContent value="basic" className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<MetricChartCard
|
||||||
|
title="CPU使用率(%)" value={currentVal('cpu')} icon={<Cpu className="h-4 w-4 text-blue-400" />}
|
||||||
|
data={metrics.cpu} color="#3b82f6" unit="%" domain={[0, 100]}
|
||||||
|
period={`周期: ${rangeHours <= 1 ? '15' : rangeHours <= 6 ? '1m' : '5m'}秒`}
|
||||||
|
/>
|
||||||
|
<MetricChartCard
|
||||||
|
title="内存使用率(%)" value={currentVal('mem')} icon={<Server className="h-4 w-4 text-emerald-400" />}
|
||||||
|
data={metrics.mem} color="#10b981" unit="%" domain={[0, 100]}
|
||||||
|
period={`周期: ${rangeHours <= 1 ? '15' : rangeHours <= 6 ? '1m' : '5m'}秒`}
|
||||||
|
/>
|
||||||
|
<MetricChartCard
|
||||||
|
title="系统平均负载(count)" value={currentVal('load1')} icon={<Activity className="h-4 w-4 text-purple-400" />}
|
||||||
|
data={metrics.load1} color="#8b5cf6" unit="" domain={[0, 'auto']}
|
||||||
|
period={`周期: ${rangeHours <= 1 ? '15' : rangeHours <= 6 ? '1m' : '5m'}秒`}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ═══ 磁盘监控: 使用率 / BPS / IOPS ═══ */}
|
||||||
|
<TabsContent value="disk" className="mt-3 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<MetricChartCard
|
||||||
|
title="磁盘使用率(%)" value={currentVal('disk')} icon={<HardDrive className="h-4 w-4 text-orange-400" />}
|
||||||
|
data={metrics.disk} color="#f97316" unit="%" domain={[0, 100]}
|
||||||
|
period={`周期: ${rangeHours <= 1 ? '15' : rangeHours <= 6 ? '1m' : '5m'}秒`}
|
||||||
|
/>
|
||||||
|
<MetricChartCard
|
||||||
|
title="所有磁盘读取BPS(B/s)" value={currentVal('ioR')} icon={<Disc className="h-4 w-4 text-blue-400" />}
|
||||||
|
data={metrics.ioRead} color="#3b82f6" unit="KB/s" domain={[0, 'auto']}
|
||||||
|
period="平均值"
|
||||||
|
/>
|
||||||
|
<MetricChartCard
|
||||||
|
title="所有磁盘写入BPS(B/s)" value={currentVal('ioW')} icon={<Disc className="h-4 w-4 text-emerald-400" />}
|
||||||
|
data={metrics.ioWrite} color="#10b981" unit="KB/s" domain={[0, 'auto']}
|
||||||
|
period="平均值"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<MetricChartCard
|
||||||
|
title="所有磁盘每秒读取次数(count/s)" value={currentVal('ioR_ops')} icon={<Zap className="h-4 w-4 text-blue-400" />}
|
||||||
|
data={metrics.ioReadOps} color="#6366f1" unit="count/s" domain={[0, 'auto']}
|
||||||
|
period="平均值"
|
||||||
|
/>
|
||||||
|
<MetricChartCard
|
||||||
|
title="所有磁盘每秒写入次数(count/s)" value={currentVal('ioW_ops')} icon={<Zap className="h-4 w-4 text-emerald-400" />}
|
||||||
|
data={metrics.ioWriteOps} color="#22d3ee" unit="count/s" domain={[0, 'auto']}
|
||||||
|
period="平均值"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ═══ 网络监控: TCP连接 / 接收 / 发送 ═══ */}
|
||||||
|
<TabsContent value="network" className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<MetricChartCard
|
||||||
|
title="TCP连接数(个)" value={currentVal('tcp')} icon={<Network className="h-4 w-4 text-purple-400" />}
|
||||||
|
data={metrics.tcpEst} color="#8b5cf6" unit="个" domain={[0, 'auto']}
|
||||||
|
period={`ESTABLISHED / TIME_WAIT ${netInfo.tcp_time_wait || 0}`}
|
||||||
|
/>
|
||||||
|
<MetricChartCard
|
||||||
|
title="网络接收速率(KB/s)" value={currentVal('rx')} icon={<Network className="h-4 w-4 text-blue-400" />}
|
||||||
|
data={metrics.rx} color="#3b82f6" unit="KB/s" domain={[0, 'auto']}
|
||||||
|
period={netInfo.nic_name ? `网卡: ${netInfo.nic_name}` : '平均值'}
|
||||||
|
/>
|
||||||
|
<MetricChartCard
|
||||||
|
title="网络发送速率(KB/s)" value={currentVal('tx')} icon={<Network className="h-4 w-4 text-emerald-400" />}
|
||||||
|
data={metrics.tx} color="#10b981" unit="KB/s" domain={[0, 'auto']}
|
||||||
|
period={netInfo.nic_name ? `网卡: ${netInfo.nic_name}` : '平均值'}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ═══ 系统信息 ═══ */}
|
||||||
|
<TabsContent value="system" className="mt-3 space-y-3">
|
||||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-xs">
|
||||||
|
{[
|
||||||
|
['主机名', serverInfo?.hostname || selectedHost],
|
||||||
|
['IP 地址', serverInfo?.ip || '—'],
|
||||||
|
['操作系统', serverInfo?.os || '—'],
|
||||||
|
['内核版本', serverInfo?.kernel || '—'],
|
||||||
|
['CPU 核心数', serverInfo?.cpu_cores || '—'],
|
||||||
|
['总内存', serverInfo?.mem_total ? `${serverInfo.mem_total.toFixed(0)} GB` : '—'],
|
||||||
|
['已用内存', serverInfo?.mem_used ? `${serverInfo.mem_used.toFixed(1)} GB` : '—'],
|
||||||
|
['运行时长', serverInfo?.uptime ? `${Math.floor(serverInfo.uptime / 86400)}天 ${Math.floor((serverInfo.uptime % 86400) / 3600)}时` : '—'],
|
||||||
|
['Agent 版本', serverInfo?.agent_version || '—'],
|
||||||
|
['磁盘总容量', serverInfo?.disk_total ? `${serverInfo.disk_total} GB` : '—'],
|
||||||
|
['监听端口数', portInfo.length > 0 ? `${portInfo.length}` : '—'],
|
||||||
|
].map(([label, val]) => (
|
||||||
|
<div key={label as string}>
|
||||||
|
<span className="text-[var(--dg-text-muted)]">{label}:</span>
|
||||||
|
<span className="text-[var(--dg-text-primary)] font-mono">{val}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Load Average */}
|
||||||
|
{latest && (
|
||||||
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">系统负载 (Load Average)</h3>
|
||||||
CPU / 内存 趋势(5m)
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-64">
|
<div className="flex items-center gap-6 text-xs">
|
||||||
{chartLoading ? (
|
<div><span className="text-[var(--dg-text-muted)]">1min </span><span className="text-sm font-bold text-slate-200">{latest.load1 ?? '--'}</span></div>
|
||||||
<div className="h-full flex items-center justify-center">
|
<div><span className="text-[var(--dg-text-muted)]">5min </span><span className="text-sm font-bold text-slate-200">{latest.load5 ?? '--'}</span></div>
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-slate-500" />
|
<div><span className="text-[var(--dg-text-muted)]">15min </span><span className="text-sm font-bold text-slate-200">{latest.load15 ?? '--'}</span></div>
|
||||||
</div>
|
</div>
|
||||||
) : chartData.length === 0 ? (
|
</CardContent>
|
||||||
<div className="h-full flex items-center justify-center text-sm text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
</Card>
|
||||||
{selectedHost ? '暂无 K 线数据 — 请确认 Agent 已上报' : '请先选择主机'}
|
)}
|
||||||
|
|
||||||
|
{/* 磁盘分区详情 */}
|
||||||
|
{latest?.disk_info && (() => {
|
||||||
|
const disk = parseJSON(latest.disk_info);
|
||||||
|
const parts = disk.partitions || [];
|
||||||
|
if (!parts.length) return null;
|
||||||
|
return (
|
||||||
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<HardDrive className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||||
|
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">磁盘分区</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{parts.map((p: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-[var(--dg-text-muted)] w-24 font-mono truncate">{p.mountpoint || p.path}</span>
|
||||||
|
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-blue-500" style={{ width: `${Math.min(p.used_percent || 0, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[var(--dg-text-primary)] w-20 text-right tabular-nums">{p.used || '?'}/{p.total || '?'}</span>
|
||||||
|
<span className="text-blue-400 w-12 text-right tabular-nums">{p.used_percent || 0}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* 网络接口详情 */}
|
||||||
|
{netInfo && (
|
||||||
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Network className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||||
|
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">网络接口</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||||
|
<div><span className="text-[var(--dg-text-muted)]">网卡:</span><span className="text-[var(--dg-text-primary)] font-mono">{netInfo.nic_name || '—'}</span></div>
|
||||||
|
<div><span className="text-[var(--dg-text-muted)]">接收:</span><span className="text-blue-400 font-mono">{netInfo.net_rx_bps ? `${(netInfo.net_rx_bps/1024).toFixed(1)} KB/s` : '—'}</span></div>
|
||||||
|
<div><span className="text-[var(--dg-text-muted)]">发送:</span><span className="text-emerald-400 font-mono">{netInfo.net_tx_bps ? `${(netInfo.net_tx_bps/1024).toFixed(1)} KB/s` : '—'}</span></div>
|
||||||
|
<div><span className="text-[var(--dg-text-muted)]">EST/TW/LN:</span><span className="text-[var(--dg-text-primary)] font-mono">{netInfo.tcp_established || 0}/{netInfo.tcp_time_wait || 0}/{netInfo.tcp_listen || 0}</span></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════ 指标图表卡片 ═══════
|
||||||
|
function MetricChartCard({
|
||||||
|
title, value, icon, data, color, unit, domain, period,
|
||||||
|
}: {
|
||||||
|
title: string; value: string; icon: React.ReactNode; data: { time: string; value: number }[];
|
||||||
|
color: string; unit: string; domain: [number, number | 'auto']; period: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
{/* 标题行 */}
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{icon}
|
||||||
|
<span className="text-xs text-[var(--dg-text-primary)] font-medium truncate">{title}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--dg-text-muted)]">{period}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当前值 */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-xl font-bold text-[var(--dg-text-primary)] tabular-nums">{value}</span>
|
||||||
|
{unit && <span className="text-xs text-[var(--dg-text-muted)] ml-1">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 趋势图 */}
|
||||||
|
<div className="h-36">
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">
|
||||||
|
暂无数据
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
<AreaChart data={data} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="hCpu" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={`grad-${title}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.35} />
|
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="hMem" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.35} />
|
|
||||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
|
||||||
<XAxis dataKey="time" stroke="#475569" fontSize={11} />
|
<XAxis dataKey="time" hide />
|
||||||
<YAxis stroke="#475569" fontSize={11} domain={[0, 100]} />
|
<YAxis
|
||||||
|
hide
|
||||||
|
domain={domain[1] === 'auto' ? [0, (max: number) => max * 1.1] : domain as [number, number]}
|
||||||
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: '#0f172a',
|
backgroundColor: '#0f172a', border: '1px solid #1e293b',
|
||||||
border: '1px solid #1e293b',
|
borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px',
|
||||||
borderRadius: '6px',
|
|
||||||
color: '#f8fafc',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
}}
|
||||||
|
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, title.replace(/\(.+\)/, '')]}
|
||||||
/>
|
/>
|
||||||
<Area type="monotone" dataKey="cpu" stroke="#3b82f6" fill="url(#hCpu)" name="CPU %" />
|
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={1.5} fill={`url(#grad-${title})`} />
|
||||||
<Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#hMem)" name="内存 %" />
|
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 网络 / 磁盘 IO 速率面板 */}
|
|
||||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Network className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
|
||||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">网络 / 磁盘 IO 实时速率</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* 网卡 */}
|
|
||||||
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-3 border border-[var(--dg-border)]">
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">
|
|
||||||
网卡{netInfo.nic_name ? ` (${netInfo.nic_name})` : ''} — RX / TX
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<RateItem label="↓ 接收" value={fmtBps(netInfo.net_rx_bps)} color="text-blue-400" />
|
|
||||||
<RateItem label="↑ 发送" value={fmtBps(netInfo.net_tx_bps)} color="text-emerald-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 磁盘 IO */}
|
|
||||||
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-3 border border-[var(--dg-border)]">
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">磁盘 IO — 读 / 写</div>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<RateItem label="读" value={fmtBps(diskIOInfo.read_bps)} color="text-orange-400" />
|
|
||||||
<RateItem label="写" value={fmtBps(diskIOInfo.write_bps)} color="text-purple-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* CPU Load */}
|
|
||||||
{latest && (
|
|
||||||
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-3 border border-[var(--dg-border)]">
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">系统负载 (Load Average)</div>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<span className="text-xs text-slate-500">1m</span>
|
|
||||||
<span className="text-sm font-mono text-slate-300">{latest.load1 ?? '--'}</span>
|
|
||||||
<span className="text-xs text-slate-500">5m</span>
|
|
||||||
<span className="text-sm font-mono text-slate-300">{latest.load5 ?? '--'}</span>
|
|
||||||
<span className="text-xs text-slate-500">15m</span>
|
|
||||||
<span className="text-sm font-mono text-slate-300">{latest.load15 ?? '--'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 概览卡片 */
|
|
||||||
function MetricCard({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
color,
|
|
||||||
detail,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
color: string;
|
|
||||||
detail?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${colorMap[color] ?? 'bg-slate-500/10'}`}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] truncate">{label}</p>
|
|
||||||
<p className="text-xl font-bold text-[var(--dg-text-primary)]">{value}</p>
|
|
||||||
{detail && (
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] truncate">{detail}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 速率标签 */
|
|
||||||
function RateItem({ label, value, color }: { label: string; value: string; color: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs text-slate-500">{label}</span>
|
|
||||||
<span className={`text-sm font-mono font-medium ${color}`}>{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default HostMonitor;
|
export default HostMonitor;
|
||||||
|
|
||||||
/** Top N 排行卡片 */
|
|
||||||
function TopRankCard({
|
|
||||||
title, icon, data, valueKey, unit, color,
|
|
||||||
}: {
|
|
||||||
title: string; icon: React.ReactNode; data: any[]; valueKey: string; unit: string; color: string;
|
|
||||||
}) {
|
|
||||||
const barColors: Record<string, string> = { red: 'bg-red-500', amber: 'bg-amber-500', orange: 'bg-orange-500', emerald: 'bg-emerald-500' };
|
|
||||||
const barColor = barColors[color] || 'bg-red-500';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
|
||||||
<CardContent className="p-3">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{icon}
|
|
||||||
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">{title}</h3>
|
|
||||||
</div>
|
|
||||||
{data.length === 0 ? (
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] py-4 text-center">暂无数据</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{data.map((item: any, i: number) => {
|
|
||||||
const val = Number(item[valueKey] || 0);
|
|
||||||
const barPct = Math.min(val, 100);
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex items-center gap-2 text-xs">
|
|
||||||
<span className="w-4 text-[var(--dg-text-muted)] font-mono text-right">{i + 1}</span>
|
|
||||||
<span className="w-24 truncate text-[var(--dg-text-secondary)]" title={item.hostname}>{item.hostname}</span>
|
|
||||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full ${barColor} transition-all`}
|
|
||||||
style={{ width: `${barPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="w-12 text-right font-mono text-[var(--dg-text-primary)]">{val.toFixed(1)}{unit}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -136,16 +136,18 @@ export const serverApi = {
|
|||||||
batchDelete: async (ids: number[]) => {
|
batchDelete: async (ids: number[]) => {
|
||||||
return http.post('/servers/batch-delete', { ids });
|
return http.post('/servers/batch-delete', { ids });
|
||||||
},
|
},
|
||||||
batchCreate: async (data: { servers: any[]; region_id?: string; auth_type?: string }) => {
|
batchCreate: async (data: { servers: any[]; region_id?: string; auth_type?: string; credential_id?: number }) => {
|
||||||
return http.post('/servers/batch', data);
|
return http.post('/servers/batch', data);
|
||||||
},
|
},
|
||||||
getMetrics: async (id: number | string) => {
|
getMetrics: async (id: number | string) => {
|
||||||
// 后端暂无单机 metrics,用 dashboard metrics
|
// 后端暂无单机 metrics,用 dashboard metrics
|
||||||
return http.get('/dashboard/metrics');
|
return http.get('/dashboard/metrics');
|
||||||
},
|
},
|
||||||
// 远程安装 Agent(通过 SSH 执行安装脚本)
|
// 远程安装 Agent(通过 SSH 执行安装脚本),可选传入凭据
|
||||||
installAgent: async (id: number | string, token: string) => {
|
installAgent: async (id: number | string, token: string, creds?: { username?: string; password?: string; ssh_key?: string; auth_type?: string }) => {
|
||||||
return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent?token=${encodeURIComponent(token)}`);
|
const body: any = { token };
|
||||||
|
if (creds) Object.assign(body, creds);
|
||||||
|
return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent`, body);
|
||||||
},
|
},
|
||||||
// 批量远程安装 Agent
|
// 批量远程安装 Agent
|
||||||
batchInstallAgent: async (ids: number[], token: string) => {
|
batchInstallAgent: async (ids: number[], token: string) => {
|
||||||
@@ -491,8 +493,8 @@ export const agentApi = {
|
|||||||
listTasks: (params?: Record<string, string>) => http.get('/agent/tasks', params),
|
listTasks: (params?: Record<string, string>) => http.get('/agent/tasks', params),
|
||||||
// 直连 SSH 执行命令(即时返回)
|
// 直连 SSH 执行命令(即时返回)
|
||||||
execCommand: (data: { hostname: string; command: string; timeout?: number }) => http.post('/agent/exec', data),
|
execCommand: (data: { hostname: string; command: string; timeout?: number }) => http.post('/agent/exec', data),
|
||||||
// K线聚合查询
|
// 聚合查询
|
||||||
getKline: (params: { hostname: string; metric: string; interval: string; from?: string; to?: string }) => http.get('/agent/kline', params),
|
getAggregate: (params: { hostname: string; metric: string; interval: string; from?: string; to?: string }) => http.get('/agent/aggregate', params),
|
||||||
// 触发聚合
|
// 触发聚合
|
||||||
aggregate: (data: { hostname: string; metric: string }) => http.post('/agent/aggregate', data),
|
aggregate: (data: { hostname: string; metric: string }) => http.post('/agent/aggregate', data),
|
||||||
listRules: () => http.get('/agent/rules'),
|
listRules: () => http.get('/agent/rules'),
|
||||||
@@ -500,6 +502,9 @@ export const agentApi = {
|
|||||||
updateRule: (id: number, data: any) => http.put(`/agent/rules/${id}`, data),
|
updateRule: (id: number, data: any) => http.put(`/agent/rules/${id}`, data),
|
||||||
deleteRule: (id: number) => http.delete(`/agent/rules/${id}`),
|
deleteRule: (id: number) => http.delete(`/agent/rules/${id}`),
|
||||||
quickSetup: (type?: string) => http.post('/agent/rules/quick-setup', { type: type || 'ecs' }),
|
quickSetup: (type?: string) => http.post('/agent/rules/quick-setup', { type: type || 'ecs' }),
|
||||||
|
// Agent 安装/卸载
|
||||||
|
uninstallAgent: (data: { hostname?: string; server_id?: number }) => http.post('/agent/uninstall', data),
|
||||||
|
batchUninstall: (serverIds: number[]) => http.post('/agent/batch-uninstall', { server_ids: serverIds }),
|
||||||
listAlerts: (params?: Record<string, string>) => http.get('/agent/alerts', params),
|
listAlerts: (params?: Record<string, string>) => http.get('/agent/alerts', params),
|
||||||
resolveAlert: (id: number) => http.post(`/agent/alerts/${id}/resolve`),
|
resolveAlert: (id: number) => http.post(`/agent/alerts/${id}/resolve`),
|
||||||
};
|
};
|
||||||
@@ -833,3 +838,19 @@ export const aiApi = {
|
|||||||
// 清空当前用户对话历史
|
// 清空当前用户对话历史
|
||||||
clearHistory: () => http.delete('/aiops/chat-history'),
|
clearHistory: () => http.delete('/aiops/chat-history'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== 凭据管理 API(SSH密钥/API Token/密码) ==================== // Logan.Li
|
||||||
|
export const credentialApi = {
|
||||||
|
list: () => http.get<{ list: any[]; total: number }>('/credentials'),
|
||||||
|
create: (data: { name: string; type: string; content: string; description?: string }) =>
|
||||||
|
http.post('/credentials', data),
|
||||||
|
update: (id: number | string, data: { name: string; type: string; content?: string; description?: string }) =>
|
||||||
|
http.put(`/credentials/${id}`, data),
|
||||||
|
delete: (id: number | string) => http.delete(`/credentials/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 服务器凭据关联 API
|
||||||
|
export const serverCredentialApi = {
|
||||||
|
assign: (serverId: number | string, credentialId: number) =>
|
||||||
|
http.post(`/servers/${serverId}/assign-credential`, { credential_id: credentialId }),
|
||||||
|
};
|
||||||
Reference in new issue
Block a user