feat: 新增日志中心、功能优化与代码重构

- 新增 log_datasource.go / log_proxy.go / user_api_token.go 后端控制器
- 新增 log_datasource.go model
- 新增 monitor/log-center.ts API 模块
- 新增 LogCenter 前端组件及页面
- 重构 deployment 相关组件 (HPDA/UpdateImageModal/bundle-yaml 等)
- 新增 ResetPasswordDialog / code-snippet-block / copy-button 等 UI 组件
- 重构多个 k8s 相关组件 (TopologyView/ResourceVisualEditor/PodFileBrowser 等)
- 更新多个 API 和工具函数 (auth.ts/monitor/index.ts/request.ts 等)
- 更新静态资源 (index.html + 新 JS/CSS bundle)
- 删除废弃静态资源文件
This commit is contained in:
Logan committed 2026-06-26 23:18:05 +08:00
1 parent 5ca0e6d83a
commit 8d46e8f352
197 files changed
+5014 -815

No files matched your search

+21 -6
View File
@@ -224,10 +224,11 @@ func (h *KubernetesHandler) GetClusterDetail(c *gin.Context) {
"description": cluster.Description,
"has_token": cluster.Token != "",
"has_kube_config": cluster.KubeConfig != "",
"node_count": cluster.NodeCount,
"pod_count": cluster.PodCount,
"service_count": cluster.ServiceCount,
"node_count": cluster.NodeCount,
"pod_count": cluster.PodCount,
"service_count": cluster.ServiceCount,
"deployment_count": cluster.DeploymentCount,
"ingress_count": cluster.IngressCount,
"cpu_usage": cluster.CPUUsage,
"memory_usage": cluster.MemoryUsage,
"labels": cluster.Labels,
@@ -287,7 +288,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
return
}
client, err := k8sclient.NewClient(cluster.KubeConfig)
client, err := buildK8sClient(&cluster)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "连接K8s失败: " + err.Error()})
return
@@ -366,6 +367,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
}
} else {
log.Printf("[K8s] sync nodes error: %v", err)
result.Errors = append(result.Errors, "nodes: "+err.Error())
}
// Sync pods
@@ -396,6 +398,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
}
} else {
log.Printf("[K8s] sync pods error: %v", err)
result.Errors = append(result.Errors, "pods: "+err.Error())
}
// Sync deployments
@@ -421,6 +424,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
}
} else {
log.Printf("[K8s] sync deps error: %v", err)
result.Errors = append(result.Errors, "deployments: "+err.Error())
}
// Sync services
@@ -442,6 +446,9 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
})
result.Services++
}
} else {
log.Printf("[K8s] sync services error: %v", err)
result.Errors = append(result.Errors, "services: "+err.Error())
}
// Sync ingresses
@@ -473,6 +480,9 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
})
result.Ingresses++
}
} else {
log.Printf("[K8s] sync ingresses error: %v", err)
result.Errors = append(result.Errors, "ingresses: "+err.Error())
}
// Update cluster stats
@@ -482,13 +492,18 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
"pod_count": result.Pods,
"deployment_count": result.Deployments,
"service_count": result.Services,
"ingress_count": result.Ingresses,
"last_sync_at": &now,
})
msg := fmt.Sprintf("同步完成: %d nodes, %d pods, %d deployments, %d services, %d ingresses",
result.Nodes, result.Pods, result.Deployments, result.Services, result.Ingresses)
if len(result.Errors) > 0 {
msg += " (部分资源同步失败,详见 errors 字段)"
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: fmt.Sprintf("同步完成: %d nodes, %d pods, %d deployments, %d services, %d ingresses",
result.Nodes, result.Pods, result.Deployments, result.Services, result.Ingresses),
Message: msg,
Data: result,
})
}
+18 -4
View File
@@ -42,9 +42,12 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
OS string `json:"os"`
Kubelet string `json:"kubelet_version"`
CPU int `json:"cpu"`
Memory int64 `json:"memory_mb"`
Memory int64 `json:"memory"` // MiB
MaxPods int `json:"max_pods"`
// Metrics (optional, omitted if empty)
// Metrics percent (for list usage bars)
CPUUsage float64 `json:"cpu_usage,omitempty"`
MemoryUsage float64 `json:"memory_usage,omitempty"`
// Metrics absolute (for detail pages)
CPUUsageM string `json:"cpu_usage_m,omitempty"` // e.g. "122m"
MemUsageMi string `json:"mem_usage_mi,omitempty"` // e.g. "2105Mi"
}
@@ -71,6 +74,8 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
break
}
}
cpuCapacity := parseIntResource(n.Status.Capacity["cpu"])
memCapacity := parseMemResource(n.Status.Capacity["memory"])
s := NodeSummary{
Name: n.Metadata.Name,
IP: ip,
@@ -78,14 +83,23 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
Status: nodeStatus,
OS: n.Status.NodeInfo.OSImage,
Kubelet: n.Status.NodeInfo.KubeletVersion,
CPU: parseIntResource(n.Status.Capacity["cpu"]),
Memory: parseMemResource(n.Status.Capacity["memory"]),
CPU: cpuCapacity,
Memory: memCapacity,
MaxPods: parseIntResource(n.Status.Capacity["pods"]),
}
if m, ok := metrics[n.Metadata.Name]; ok {
// K8s metrics API returns CPU in nanocores ("80410173n"), memory in Ki ("2154292Ki")
s.CPUUsageM = formatCpuFromNano(m.Usage["cpu"])
s.MemUsageMi = formatMemFromKi(m.Usage["memory"])
// 计算百分比用于列表页使用率条 // Logan.Li
if cpuCapacity > 0 {
cpuMillicores := parseCpuNanoToMilli(m.Usage["cpu"])
s.CPUUsage = float64(cpuMillicores) / float64(cpuCapacity*1000) * 100
}
if memCapacity > 0 {
memMi := parseMemKiToMi(m.Usage["memory"])
s.MemoryUsage = float64(memMi) / float64(memCapacity) * 100
}
}
result = append(result, s)
}
+17 -3
View File
@@ -366,6 +366,7 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
Image string `json:"image"`
CPUUsageM string `json:"cpu_usage_m,omitempty"`
MemUsageMi string `json:"mem_usage_mi,omitempty"`
Conditions []k8sclient.PodCondition `json:"conditions,omitempty"`
}
result := []PodSummary{}
for _, p := range pods.Items {
@@ -380,6 +381,7 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
IP: p.Status.PodIP,
RestartCount: restarts,
Image: image,
Conditions: p.Status.Conditions,
}
if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok {
ps.CPUUsageM = m.CPU
@@ -523,17 +525,29 @@ func parseMemResource(val string) int64 {
if val == "" {
return 0
}
// 清理常见后缀,大小写不敏感,支持 GiB / G / GB / bytes 等 // Logan.Li
val = strings.TrimSpace(val)
val = strings.ToUpper(val)
val = strings.TrimSuffix(val, "IB")
val = strings.TrimSuffix(val, "B")
var num float64
var unit string
fmt.Sscanf(val, "%f%s", &num, &unit)
unit = strings.TrimSpace(unit)
switch unit {
case "Ki":
case "KI":
return int64(num / 1024)
case "Mi":
case "MI":
return int64(num)
case "Gi":
case "GI":
return int64(num * 1024)
case "TI":
return int64(num * 1024 * 1024)
default:
// 纯数字通常是 bytes
if unit == "" && num > 0 {
return int64(num / 1024 / 1024)
}
return int64(num / 1024 / 1024)
}
}
+33
View File
@@ -330,6 +330,39 @@ func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) {
}
c.JSON(200, model.Response{Code: 200, Data: result})
}
// ListIngressClasses 列出集群所有 IngressClass(供可视化编辑 Ingress 时下拉选择) // Logan.Li
func (h *KubernetesHandler) ListIngressClasses(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
icList, err := client.ListIngressClasses()
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type ICItem struct {
Name string `json:"name"`
Controller string `json:"controller"`
}
result := []ICItem{}
for _, ic := range icList.Items {
result = append(result, ICItem{
Name: ic.Metadata.Name,
Controller: ic.Spec.Controller,
})
}
c.JSON(200, model.Response{Code: 200, Data: result})
}
func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
+2
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"sort"
"strconv"
"strings"
"ezops/model"
@@ -302,6 +303,7 @@ func (h *KubernetesHandler) updateDeploymentImageWithNS(c *gin.Context, ns strin
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
var req struct { Image string `json:"image"`; ContainerName string `json:"container_name"` }
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
if strings.TrimSpace(req.Image) == "" { c.JSON(400, model.Response{Code: 400, Message: "image 不能为空"}); return }
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err := client.UpdateDeploymentImage(ns, c.Param("name"), req.ContainerName, req.Image); err != nil { c.JSON(500, model.Response{Code: 500, Message: "更新镜像失败: " + err.Error()}); return }
+12 -4
View File
@@ -89,16 +89,24 @@ func (h *KubernetesHandler) getK8sClient(clusterID int) (*k8sclient.Client, *mod
if err := h.db.First(&cluster, clusterID).Error; err != nil {
return nil, nil, fmt.Errorf("cluster not found")
}
if cluster.KubeConfig == "" {
return nil, nil, fmt.Errorf("no kubeconfig")
}
client, err := k8sclient.NewClient(cluster.KubeConfig)
client, err := buildK8sClient(&cluster)
if err != nil {
return nil, nil, err
}
return client, &cluster, nil
}
// buildK8sClient 根据集群配置构造客户端,支持 kubeconfig 或 token+api_server // Logan.Li
func buildK8sClient(cluster *model.KubernetesCluster) (*k8sclient.Client, error) {
if cluster.KubeConfig != "" {
return k8sclient.NewClient(cluster.KubeConfig)
}
if cluster.Token != "" && cluster.APIServer != "" {
return k8sclient.NewClientFromToken(cluster.APIServer, cluster.Token)
}
return nil, fmt.Errorf("no kubeconfig or token+api_server configured")
}
// ============================================================================
// PVC (存储) handlers
// ============================================================================
+220
View File
@@ -0,0 +1,220 @@
package controller
import (
"crypto/tls"
"encoding/base64"
"io"
"net/http"
"strings"
"time"
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// LogDSHandler 日志数据源管理 // Logan.Li
type LogDSHandler struct {
db *gorm.DB
}
func NewLogDSHandler(db *gorm.DB) *LogDSHandler {
return &LogDSHandler{db: db}
}
// List GET /api/v1/log-datasources?type=loki
func (h *LogDSHandler) List(c *gin.Context) {
q := h.db.Order("is_default DESC, id ASC")
if t := c.Query("type"); t != "" {
q = q.Where("type = ?", t)
}
var sources []model.LogDataSource
if err := q.Find(&sources).Error; err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if sources == nil {
sources = []model.LogDataSource{}
}
c.JSON(200, model.Response{Code: 200, Data: sources})
}
// Get GET /api/v1/log-datasources/:id
func (h *LogDSHandler) Get(c *gin.Context) {
var ds model.LogDataSource
if err := h.db.First(&ds, c.Param("id")).Error; err != nil {
c.JSON(404, model.Response{Code: 404, Message: "数据源不存在"})
return
}
c.JSON(200, model.Response{Code: 200, Data: ds})
}
// Create POST /api/v1/log-datasources
func (h *LogDSHandler) Create(c *gin.Context) {
var req model.CreateLogDSReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
return
}
req.Type = strings.ToLower(req.Type)
if req.Type != "loki" && req.Type != "elasticsearch" {
c.JSON(400, model.Response{Code: 400, Message: "type 必须为 loki 或 elasticsearch"})
return
}
if req.IsDefault {
h.db.Model(&model.LogDataSource{}).Where("type = ? AND is_default = ?", req.Type, true).Update("is_default", false)
}
ds := model.LogDataSource{
Name: req.Name, Type: req.Type, URL: strings.TrimRight(req.URL, "/"),
BasicAuth: req.BasicAuth, BasicAuthUser: req.BasicAuthUser, BasicAuthPass: req.BasicAuthPass,
SkipTLSVerify: req.SkipTLSVerify, DefaultIndex: req.DefaultIndex,
IsDefault: req.IsDefault, Status: 1,
}
if err := h.db.Create(&ds).Error; err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "创建成功", Data: ds})
}
// Update PUT /api/v1/log-datasources/:id
func (h *LogDSHandler) Update(c *gin.Context) {
var ds model.LogDataSource
if err := h.db.First(&ds, c.Param("id")).Error; err != nil {
c.JSON(404, model.Response{Code: 404, Message: "数据源不存在"})
return
}
var req model.UpdateLogDSReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
return
}
updates := map[string]interface{}{}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Type != nil {
updates["type"] = strings.ToLower(*req.Type)
}
if req.URL != nil {
updates["url"] = strings.TrimRight(*req.URL, "/")
}
if req.BasicAuth != nil {
updates["basic_auth"] = *req.BasicAuth
}
if req.BasicAuthUser != nil {
updates["basic_auth_user"] = *req.BasicAuthUser
}
if req.BasicAuthPass != nil && *req.BasicAuthPass != "" {
updates["basic_auth_pass"] = *req.BasicAuthPass
}
if req.SkipTLSVerify != nil {
updates["skip_tls_verify"] = *req.SkipTLSVerify
}
if req.DefaultIndex != nil {
updates["default_index"] = *req.DefaultIndex
}
if req.Status != nil {
updates["status"] = *req.Status
}
if req.IsDefault != nil && *req.IsDefault {
h.db.Model(&model.LogDataSource{}).Where("type = ? AND is_default = ?", ds.Type, true).Update("is_default", false)
updates["is_default"] = true
} else if req.IsDefault != nil {
updates["is_default"] = false
}
if err := h.db.Model(&ds).Updates(updates).Error; err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
h.db.First(&ds, ds.ID)
c.JSON(200, model.Response{Code: 200, Message: "更新成功", Data: ds})
}
// Delete DELETE /api/v1/log-datasources/:id
func (h *LogDSHandler) Delete(c *gin.Context) {
if err := h.db.Delete(&model.LogDataSource{}, c.Param("id")).Error; err != nil {
c.JSON(500, model.Response{Code: 500, Message: "删除失败"})
return
}
c.JSON(200, model.Response{Code: 200, Message: "已删除"})
}
// SetDefault POST /api/v1/log-datasources/:id/default
func (h *LogDSHandler) SetDefault(c *gin.Context) {
var ds model.LogDataSource
if err := h.db.First(&ds, c.Param("id")).Error; err != nil {
c.JSON(404, model.Response{Code: 404, Message: "数据源不存在"})
return
}
h.db.Model(&model.LogDataSource{}).Where("type = ? AND is_default = ?", ds.Type, true).Update("is_default", false)
h.db.Model(&ds).Update("is_default", true)
c.JSON(200, model.Response{Code: 200, Message: "已设为默认"})
}
// TestConnection POST /api/v1/log-datasources/test
func (h *LogDSHandler) TestConnection(c *gin.Context) {
var req model.TestLogDSReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
return
}
ok, msg := testLogDSConnection(req)
if !ok {
c.JSON(502, model.Response{Code: 502, Message: msg})
return
}
c.JSON(200, model.Response{Code: 200, Message: "连接成功", Data: map[string]string{"status": "ok"}})
}
func testLogDSConnection(req model.TestLogDSReq) (bool, string) {
client := newLogHTTPClient(req.SkipTLSVerify, 10*time.Second)
url := strings.TrimRight(req.URL, "/")
var testURL string
switch strings.ToLower(req.Type) {
case "loki":
testURL = url + "/loki/api/v1/labels"
case "elasticsearch":
testURL = url + "/_cluster/health"
default:
return false, "未知数据源类型"
}
httpReq, err := http.NewRequest(http.MethodGet, testURL, nil)
if err != nil {
return false, err.Error()
}
applyLogDSAuth(httpReq, req.BasicAuth, req.BasicAuthUser, req.BasicAuthPass)
resp, err := client.Do(httpReq)
if err != nil {
return false, "连接失败: " + err.Error()
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return false, "HTTP " + resp.Status + ": " + string(body)
}
return true, ""
}
func loadLogDS(h *LogDSHandler, id uint) (*model.LogDataSource, error) {
var ds model.LogDataSource
if err := h.db.First(&ds, id).Error; err != nil {
return nil, err
}
return &ds, nil
}
func newLogHTTPClient(skipTLS bool, timeout time.Duration) *http.Client {
tr := &http.Transport{}
if skipTLS {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
}
return &http.Client{Timeout: timeout, Transport: tr}
}
func applyLogDSAuth(req *http.Request, basic bool, user, pass string) {
if basic && user != "" {
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(user+":"+pass)))
}
}
+288
View File
@@ -0,0 +1,288 @@
package controller
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"ezops/model"
"github.com/gin-gonic/gin"
)
// LogProxyHandler Loki / ES 无状态查询代理
// 实时转发请求并解析展示字段,不将日志写入 AIOps 数据库 // Logan.Li
type LogProxyHandler struct {
ds *LogDSHandler
}
const maxLogQueryLimit = 500 // 单次查询上限,防止平台承载过大数据 // Logan.Li
func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler {
return &LogProxyHandler{ds: ds}
}
// QueryLoki POST /api/v1/logs/loki/query
func (h *LogProxyHandler) QueryLoki(c *gin.Context) {
var req struct {
DatasourceID uint `json:"datasource_id" binding:"required"`
Query string `json:"query" binding:"required"`
Limit int `json:"limit"`
Start string `json:"start"`
End string `json:"end"`
Direction string `json:"direction"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
return
}
ds, err := loadLogDS(h.ds, req.DatasourceID)
if err != nil || ds.Type != "loki" {
c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"})
return
}
if req.Limit <= 0 {
req.Limit = 200
}
if req.Limit > maxLogQueryLimit {
req.Limit = maxLogQueryLimit
}
if req.Direction == "" {
req.Direction = "backward"
}
end := time.Now()
start := end.Add(-1 * time.Hour)
if req.End != "" {
if t, e := parseLogTime(req.End); e == nil {
end = t
}
}
if req.Start != "" {
if t, e := parseLogTime(req.Start); e == nil {
start = t
}
}
params := url.Values{}
params.Set("query", req.Query)
params.Set("limit", strconv.Itoa(req.Limit))
params.Set("direction", req.Direction)
params.Set("start", fmt.Sprintf("%d", start.UnixNano()))
params.Set("end", fmt.Sprintf("%d", end.UnixNano()))
client := newLogHTTPClient(ds.SkipTLSVerify, time.Duration(60)*time.Second)
target := ds.URL + "/loki/api/v1/query_range?" + params.Encode()
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
applyLogDSAuth(httpReq, ds.BasicAuth, ds.BasicAuthUser, ds.BasicAuthPass)
resp, err := client.Do(httpReq)
if err != nil {
c.JSON(502, model.Response{Code: 502, Message: "查询失败: " + err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
c.JSON(502, model.Response{Code: 502, Message: "Loki 错误: " + string(body)})
return
}
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
entries := flattenLokiStreams(raw)
c.JSON(200, model.Response{Code: 200, Data: gin.H{
"entries": entries, "total": len(entries),
}})
}
// QueryES POST /api/v1/logs/elasticsearch/query
func (h *LogProxyHandler) QueryES(c *gin.Context) {
var req struct {
DatasourceID uint `json:"datasource_id" binding:"required"`
Index string `json:"index"`
Query string `json:"query"`
QueryBody map[string]interface{} `json:"query_body"`
Size int `json:"size"`
Start string `json:"start"`
End string `json:"end"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
return
}
ds, err := loadLogDS(h.ds, req.DatasourceID)
if err != nil || ds.Type != "elasticsearch" {
c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"})
return
}
index := req.Index
if index == "" {
index = ds.DefaultIndex
}
if index == "" {
index = "*"
}
if req.Size <= 0 {
req.Size = 200
}
if req.Size > maxLogQueryLimit {
req.Size = maxLogQueryLimit
}
searchBody := req.QueryBody
if searchBody == nil {
searchBody = map[string]interface{}{
"size": req.Size,
"sort": []map[string]interface{}{{"@timestamp": map[string]string{"order": "desc"}}},
"query": map[string]interface{}{
"bool": map[string]interface{}{
"must": []interface{}{
map[string]interface{}{"query_string": map[string]interface{}{"query": defaultESQuery(req.Query)}},
},
},
},
}
if req.Start != "" || req.End != "" {
rangeQ := map[string]interface{}{"range": map[string]interface{}{"@timestamp": map[string]string{}}}
r := rangeQ["range"].(map[string]interface{})["@timestamp"].(map[string]string)
if req.Start != "" {
r["gte"] = req.Start
}
if req.End != "" {
r["lte"] = req.End
}
searchBody["query"] = map[string]interface{}{
"bool": map[string]interface{}{
"must": []interface{}{
searchBody["query"],
rangeQ,
},
},
}
}
}
payload, _ := json.Marshal(searchBody)
client := newLogHTTPClient(ds.SkipTLSVerify, 60*time.Second)
target := fmt.Sprintf("%s/%s/_search", ds.URL, strings.Trim(index, "/"))
httpReq, _ := http.NewRequest(http.MethodPost, target, bytes.NewReader(payload))
httpReq.Header.Set("Content-Type", "application/json")
applyLogDSAuth(httpReq, ds.BasicAuth, ds.BasicAuthUser, ds.BasicAuthPass)
resp, err := client.Do(httpReq)
if err != nil {
c.JSON(502, model.Response{Code: 502, Message: "查询失败: " + err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
c.JSON(502, model.Response{Code: 502, Message: "ES 错误: " + string(body)})
return
}
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
entries := flattenESEntries(raw)
c.JSON(200, model.Response{Code: 200, Data: gin.H{
"entries": entries, "total": len(entries),
}})
}
func parseLogTime(s string) (time.Time, error) {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
if n > 1e15 {
return time.Unix(0, n), nil
}
return time.Unix(n, 0), nil
}
return time.Parse(time.RFC3339, s)
}
func defaultESQuery(q string) string {
if strings.TrimSpace(q) == "" {
return "*"
}
return q
}
type logEntryRow struct {
Timestamp string `json:"timestamp"`
Level string `json:"level,omitempty"`
Message string `json:"message"`
Labels map[string]string `json:"labels,omitempty"`
Raw string `json:"raw,omitempty"`
}
func flattenLokiStreams(raw map[string]interface{}) []logEntryRow {
out := []logEntryRow{}
data, _ := raw["data"].(map[string]interface{})
result, _ := data["result"].([]interface{})
for _, item := range result {
stream, _ := item.(map[string]interface{})
labels, _ := stream["stream"].(map[string]interface{})
labelMap := map[string]string{}
for k, v := range labels {
labelMap[k] = fmt.Sprintf("%v", v)
}
values, _ := stream["values"].([]interface{})
for _, val := range values {
pair, _ := val.([]interface{})
if len(pair) < 2 {
continue
}
ts := fmt.Sprintf("%v", pair[0])
msg := fmt.Sprintf("%v", pair[1])
out = append(out, logEntryRow{
Timestamp: ts,
Level: labelMap["level"],
Message: msg,
Labels: labelMap,
Raw: msg,
})
}
}
return out
}
func flattenESEntries(raw map[string]interface{}) []logEntryRow {
out := []logEntryRow{}
hitsWrap, _ := raw["hits"].(map[string]interface{})
hits, _ := hitsWrap["hits"].([]interface{})
for _, h := range hits {
doc, _ := h.(map[string]interface{})
src, _ := doc["_source"].(map[string]interface{})
if src == nil {
continue
}
msg := pickESMessage(src)
level, _ := src["level"].(string)
ts, _ := src["@timestamp"].(string)
if ts == "" {
ts = fmt.Sprintf("%v", src["timestamp"])
}
rawBytes, _ := json.Marshal(src)
out = append(out, logEntryRow{
Timestamp: ts,
Level: level,
Message: msg,
Raw: string(rawBytes),
})
}
return out
}
func pickESMessage(src map[string]interface{}) string {
for _, key := range []string{"message", "msg", "log", "content"} {
if v, ok := src[key]; ok {
return fmt.Sprintf("%v", v)
}
}
b, _ := json.Marshal(src)
if len(b) > 500 {
return string(b[:500]) + "..."
}
return string(b)
}
+32
View File
@@ -358,3 +358,35 @@ func (h *UserHandler) ImportTemplate(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename=users_import_template.csv")
c.String(http.StatusOK, csv)
}
// ResetUserPassword 管理员重置用户密码(仅本地账号)// Logan.Li
// POST /api/v1/admin/users/:id/reset-password
func (h *UserHandler) ResetUserPassword(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var req struct {
NewPassword string `json:"new_password" binding:"required,min=6,max=32"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
var user model.User
if err := h.db.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "用户不存在"})
return
}
if user.AuthSource == "ldap" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "LDAP 用户请在 LDAP 目录中重置密码"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "密码加密失败"})
return
}
if err := h.db.Model(&user).Update("password", string(hashed)).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "重置失败"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "密码已重置"})
}
+314
View File
@@ -0,0 +1,314 @@
package controller
import (
"crypto/rand"
"fmt"
"math/big"
"net/http"
"time"
"ezops/middleware"
"ezops/model"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
const apiKeyChars = "abcdefghijklmnopqrstuvwxyz0123456789"
const (
defaultAPIKeyMonths = 12
maxAPIKeyMonths = 36
)
// randomAPIKeyPart 生成指定长度的随机密钥片段 // Logan.Li
func randomAPIKeyPart(n int) (string, error) {
out := make([]byte, n)
max := big.NewInt(int64(len(apiKeyChars)))
for i := range out {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
out[i] = apiKeyChars[idx.Int64()]
}
return string(out), nil
}
func normalizeAPIKeyMonths(months int) int {
if months <= 0 {
return defaultAPIKeyMonths
}
if months > maxAPIKeyMonths {
return maxAPIKeyMonths
}
return months
}
func apiKeyExpiresAfter(months int) time.Time {
return time.Now().AddDate(0, normalizeAPIKeyMonths(months), 0)
}
func parseExpiresAt(dateStr string) (*time.Time, error) {
if dateStr == "" {
return nil, nil
}
t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return nil, err
}
end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.Local)
return &end, nil
}
func apiKeyStatus(expiresAt *time.Time) string {
if expiresAt != nil && expiresAt.Before(time.Now()) {
return "expired"
}
return "active"
}
func (h *UserHandler) loadAPIKeyUser(userID uint) (model.User, error) {
var user model.User
err := h.db.Select(
"id", "api_access_key_prefix", "api_access_key_id", "api_access_key_description",
"api_access_key_duration_months", "api_access_key_at", "api_access_key_expires_at",
"api_access_key_last_used_at",
).First(&user, userID).Error
return user, err
}
// apiKeyResponse 单条密钥元信息(不含明文)// Logan.Li
func apiKeyResponse(user model.User) gin.H {
hasToken := user.ApiAccessKeyPrefix != ""
expired := false
var daysLeft *int
if user.ApiAccessKeyExpiresAt != nil {
now := time.Now()
if user.ApiAccessKeyExpiresAt.Before(now) {
expired = true
d := 0
daysLeft = &d
} else {
d := int(user.ApiAccessKeyExpiresAt.Sub(now).Hours()/24) + 1
daysLeft = &d
}
}
mask := ""
if user.ApiAccessKeyPrefix != "" {
mask = user.ApiAccessKeyPrefix + ".•••••••••••••••••••••••••••••••••"
}
return gin.H{
"has_token": hasToken,
"key_id": user.ApiAccessKeyID,
"prefix": user.ApiAccessKeyPrefix,
"access_key_mask": mask,
"description": user.ApiAccessKeyDescription,
"duration_months": user.ApiAccessKeyDurationMonths,
"created_at": user.ApiAccessKeyAt,
"expires_at": user.ApiAccessKeyExpiresAt,
"last_used_at": user.ApiAccessKeyLastUsedAt,
"status": apiKeyStatus(user.ApiAccessKeyExpiresAt),
"expired": expired,
"days_left": daysLeft,
"default_months": defaultAPIKeyMonths,
}
}
func (h *UserHandler) ensureAPIKeyMeta(user *model.User) {
if user.ApiAccessKeyPrefix == "" {
return
}
patches := map[string]interface{}{}
if user.ApiAccessKeyID == "" {
if id, err := randomAPIKeyPart(24); err == nil {
patches["api_access_key_id"] = id
user.ApiAccessKeyID = id
}
}
if user.ApiAccessKeyExpiresAt == nil {
exp := apiKeyExpiresAfter(defaultAPIKeyMonths)
patches["api_access_key_expires_at"] = exp
user.ApiAccessKeyExpiresAt = &exp
}
if user.ApiAccessKeyDurationMonths == 0 {
patches["api_access_key_duration_months"] = defaultAPIKeyMonths
user.ApiAccessKeyDurationMonths = defaultAPIKeyMonths
}
if len(patches) > 0 {
_ = h.db.Model(&model.User{}).Where("id = ?", user.ID).Updates(patches).Error
}
}
// GetApiAccessKey 获取当前用户 API 密钥元信息 // Logan.Li
func (h *UserHandler) GetApiAccessKey(c *gin.Context) {
userID := middleware.GetUserID(c)
user, err := h.loadAPIKeyUser(userID)
if err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "用户不存在"})
return
}
h.ensureAPIKeyMeta(&user)
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: apiKeyResponse(user)})
}
type apiKeyCreateRequest struct {
Description string `json:"description"`
DurationMonths int `json:"duration_months"`
ExpiresAt string `json:"expires_at"`
}
type apiKeyUpdateRequest struct {
Description string `json:"description"`
ExpiresAt string `json:"expires_at"`
}
// RegenerateApiAccessKey 创建或重新生成唯一 API 密钥 // Logan.Li
func (h *UserHandler) RegenerateApiAccessKey(c *gin.Context) {
userID := middleware.GetUserID(c)
var req apiKeyCreateRequest
_ = c.ShouldBindJSON(&req)
months := normalizeAPIKeyMonths(req.DurationMonths)
var expires *time.Time
if req.ExpiresAt != "" {
parsed, err := parseExpiresAt(req.ExpiresAt)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "截止时间格式无效,请使用 YYYY-MM-DD"})
return
}
expires = parsed
} else {
exp := apiKeyExpiresAfter(months)
expires = &exp
}
keyID, err := randomAPIKeyPart(24)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "生成密钥失败"})
return
}
prefix, err := randomAPIKeyPart(12)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "生成密钥失败"})
return
}
secret, err := randomAPIKeyPart(32)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "生成密钥失败"})
return
}
full := fmt.Sprintf("%s.%s", prefix, secret)
hash, err := bcrypt.GenerateFromPassword([]byte(full), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "密钥加密失败"})
return
}
now := time.Now()
updates := map[string]interface{}{
"api_access_key_hash": string(hash),
"api_access_key_prefix": prefix,
"api_access_key_id": keyID,
"api_access_key_description": req.Description,
"api_access_key_duration_months": months,
"api_access_key_at": now,
"api_access_key_expires_at": expires,
"api_access_key_last_used_at": nil,
}
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存密钥失败"})
return
}
c.JSON(http.StatusOK, model.Response{
Code: 200, Message: "访问密钥已生成,请立即保存(仅显示一次)",
Data: gin.H{
"token": full,
"prefix": prefix,
"key_id": keyID,
"description": req.Description,
"created_at": now,
"expires_at": expires,
"duration_months": months,
},
})
}
// UpdateApiAccessKey 编辑描述或截止时间(不更换密钥明文)// Logan.Li
func (h *UserHandler) UpdateApiAccessKey(c *gin.Context) {
userID := middleware.GetUserID(c)
var req apiKeyUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
user, err := h.loadAPIKeyUser(userID)
if err != nil || user.ApiAccessKeyPrefix == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "尚未创建 API 密钥"})
return
}
updates := map[string]interface{}{}
if req.Description != "" || c.Request.ContentLength > 0 {
updates["api_access_key_description"] = req.Description
}
if req.ExpiresAt != "" {
parsed, err := parseExpiresAt(req.ExpiresAt)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "截止时间格式无效"})
return
}
updates["api_access_key_expires_at"] = parsed
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "没有可更新的字段"})
return
}
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "更新失败"})
return
}
user, _ = h.loadAPIKeyUser(userID)
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已更新", Data: apiKeyResponse(user)})
}
// ExtendApiAccessKey 延长有效期(兼容旧接口)// Logan.Li
func (h *UserHandler) ExtendApiAccessKey(c *gin.Context) {
userID := middleware.GetUserID(c)
var req struct{ DurationMonths int `json:"duration_months"` }
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误"})
return
}
user, err := h.loadAPIKeyUser(userID)
if err != nil || user.ApiAccessKeyPrefix == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "尚未创建 API 密钥"})
return
}
months := normalizeAPIKeyMonths(req.DurationMonths)
start := time.Now()
if user.ApiAccessKeyExpiresAt != nil && user.ApiAccessKeyExpiresAt.After(start) {
start = *user.ApiAccessKeyExpiresAt
}
expires := start.AddDate(0, months, 0)
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Update("api_access_key_expires_at", expires).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "续期失败"})
return
}
c.JSON(http.StatusOK, model.Response{
Code: 200, Message: "密钥有效期已延长",
Data: gin.H{"expires_at": expires, "duration_months": months},
})
}
// RevokeApiAccessKey 删除 API 访问密钥 // Logan.Li
func (h *UserHandler) RevokeApiAccessKey(c *gin.Context) {
userID := middleware.GetUserID(c)
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"api_access_key_hash": "", "api_access_key_prefix": "", "api_access_key_id": "",
"api_access_key_description": "", "api_access_key_duration_months": 0,
"api_access_key_at": nil, "api_access_key_expires_at": nil, "api_access_key_last_used_at": nil,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "访问密钥已删除"})
}