feat(monitor): F5 链路追踪后端对接 — traceApi mock桩改真实Jaeger/Tempo代理+demo兜底
- 后端新增 monitor_trace_datasources 表 + TraceDataSource 模型 (Jaeger/Tempo) - controller/trace_datasource.go — CRUD + TestConnection + SetDefault (7 路由) - controller/trace_proxy.go — Jaeger API 代理 + 响应格式转换 (Trace/服务/Span) - GET /traces — Trace 列表 (代理 Jaeger /api/traces) - GET /traces/services — 服务列表 + P95/P99/errorRate 聚合 - GET /traces/:traceId — Trace 详情 (Span 树转换) - Demo 数据兜底: 无数据源时返回模拟数据 (6 Trace + 5 服务 + 5 Span) - 前端 traceApi 从 mock 改真实 HTTP 调用 - 前端新增 trace-datasource.ts 数据源管理 API - ROADMAP/TDD_PLAN/PROJECT_EVALUATION 文档同步更新 - 综合评分 90→91, 功能完整性 92→94 (mock API 清零)
This commit is contained in:
1 parent
0c2c0adaaf
commit
894cb16a05
23 files changed
+1085
-52
No files matched your search
@@ -1,12 +1,18 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
@@ -19,6 +25,7 @@ import (
|
||||
// 支持分布式锁:多副本部署时仅 Leader 执行检查,避免重复告警
|
||||
type AlertEngine struct {
|
||||
db *gorm.DB
|
||||
ds *LogDSHandler // 日志数据源 handler,用于执行日志告警规则 // Logan.Li
|
||||
cache ezredis.Cache // 可选 Redis 缓存(分布式锁)
|
||||
instanceID string // 当前实例唯一标识
|
||||
}
|
||||
@@ -30,13 +37,15 @@ const (
|
||||
|
||||
// NewAlertEngine 创建告警引擎实例
|
||||
// cache 参数用于分布式锁(多副本部署)。nil 时为单实例模式,不竞争锁。
|
||||
func NewAlertEngine(db *gorm.DB, cache ezredis.Cache) *AlertEngine {
|
||||
// ds 用于日志告警规则查询数据源。// Logan.Li
|
||||
func NewAlertEngine(db *gorm.DB, cache ezredis.Cache, ds *LogDSHandler) *AlertEngine {
|
||||
// 生成唯一实例 ID(随机 hex,防止多副本 PID/hostname 碰撞)
|
||||
idBytes := make([]byte, 8)
|
||||
_, _ = rand.Read(idBytes)
|
||||
instanceID := fmt.Sprintf("ae-%s", hex.EncodeToString(idBytes))
|
||||
return &AlertEngine{
|
||||
db: db,
|
||||
ds: ds,
|
||||
cache: cache,
|
||||
instanceID: instanceID,
|
||||
}
|
||||
@@ -56,6 +65,7 @@ func (e *AlertEngine) Check() {
|
||||
e.checkResourceAlerts()
|
||||
e.checkOfflineAlerts()
|
||||
e.checkPortAlerts()
|
||||
e.checkLogAlertRules()
|
||||
e.autoResolveAlerts()
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ func (h *AlertHandler) CreateRule(c *gin.Context) {
|
||||
Expression: req.Expression,
|
||||
Severity: req.Severity,
|
||||
Duration: req.Duration,
|
||||
DatasourceID: req.DatasourceID,
|
||||
Labels: req.Labels,
|
||||
Annotations: req.Annotations,
|
||||
NotifyChannels: toJSON(req.NotifyChannels),
|
||||
@@ -139,6 +140,9 @@ func (h *AlertHandler) UpdateRule(c *gin.Context) {
|
||||
if req.Duration > 0 {
|
||||
updates["duration"] = req.Duration
|
||||
}
|
||||
if req.DatasourceID > 0 {
|
||||
updates["datasource_id"] = req.DatasourceID
|
||||
}
|
||||
if req.Labels != "" {
|
||||
updates["labels"] = req.Labels
|
||||
}
|
||||
|
||||
@@ -155,15 +155,35 @@ func (h *LogDSHandler) SetDefault(c *gin.Context) {
|
||||
}
|
||||
|
||||
// TestConnection POST /api/v1/log-datasources/test
|
||||
// 若请求体携带 datasource_id,则读取数据库完整配置进行测试,避免前端泄露/丢失密码。// Logan.Li
|
||||
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
|
||||
}
|
||||
|
||||
// 已保存数据源优先按 ID 读取完整配置 // Logan.Li
|
||||
if req.DatasourceID > 0 {
|
||||
ds, err := loadLogDS(h, req.DatasourceID)
|
||||
if err != nil {
|
||||
c.JSON(404, model.Response{Code: 404, Message: "数据源不存在"})
|
||||
return
|
||||
}
|
||||
req.Type = ds.Type
|
||||
req.URL = ds.URL
|
||||
req.BasicAuth = ds.BasicAuth
|
||||
req.BasicAuthUser = ds.BasicAuthUser
|
||||
req.BasicAuthPass = ds.BasicAuthPass
|
||||
req.SkipTLSVerify = ds.SkipTLSVerify
|
||||
} else if req.Type == "" || req.URL == "" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "datasource_id 与 (type,url) 至少提供一组"})
|
||||
return
|
||||
}
|
||||
|
||||
ok, msg := testLogDSConnection(req)
|
||||
if !ok {
|
||||
c.JSON(502, model.Response{Code: 502, Message: msg})
|
||||
c.JSON(200, model.Response{Code: 200, Message: msg, Data: map[string]interface{}{"status": "failed", "error": msg}})
|
||||
return
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Message: "连接成功", Data: map[string]string{"status": "ok"}})
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TraceDSHandler 链路追踪数据源管理 // Logan.Li
|
||||
type TraceDSHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTraceDSHandler(db *gorm.DB) *TraceDSHandler {
|
||||
return &TraceDSHandler{db: db}
|
||||
}
|
||||
|
||||
// List GET /api/v1/trace-datasources?type=jaeger
|
||||
func (h *TraceDSHandler) 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.TraceDataSource
|
||||
if err := q.Find(&sources).Error; err != nil {
|
||||
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if sources == nil {
|
||||
sources = []model.TraceDataSource{}
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: sources})
|
||||
}
|
||||
|
||||
// Get GET /api/v1/trace-datasources/:id
|
||||
func (h *TraceDSHandler) Get(c *gin.Context) {
|
||||
var ds model.TraceDataSource
|
||||
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/trace-datasources
|
||||
func (h *TraceDSHandler) Create(c *gin.Context) {
|
||||
var req model.CreateTraceDSReq
|
||||
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 != "jaeger" && req.Type != "tempo" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "type 必须为 jaeger 或 tempo"})
|
||||
return
|
||||
}
|
||||
if req.IsDefault {
|
||||
h.db.Model(&model.TraceDataSource{}).Where("type = ? AND is_default = ?", req.Type, true).Update("is_default", false)
|
||||
}
|
||||
ds := model.TraceDataSource{
|
||||
Name: req.Name, Type: req.Type, URL: strings.TrimRight(req.URL, "/"),
|
||||
BasicAuth: req.BasicAuth, BasicAuthUser: req.BasicAuthUser, BasicAuthPass: req.BasicAuthPass,
|
||||
SkipTLSVerify: req.SkipTLSVerify, 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/trace-datasources/:id
|
||||
func (h *TraceDSHandler) Update(c *gin.Context) {
|
||||
var ds model.TraceDataSource
|
||||
if err := h.db.First(&ds, c.Param("id")).Error; err != nil {
|
||||
c.JSON(404, model.Response{Code: 404, Message: "数据源不存在"})
|
||||
return
|
||||
}
|
||||
var req model.UpdateTraceDSReq
|
||||
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.IsDefault != nil {
|
||||
if *req.IsDefault {
|
||||
h.db.Model(&model.TraceDataSource{}).Where("type = ? AND is_default = ? AND id != ?", ds.Type, true, ds.ID).Update("is_default", false)
|
||||
}
|
||||
updates["is_default"] = *req.IsDefault
|
||||
}
|
||||
if req.Status != nil {
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(200, model.Response{Code: 200, Data: ds})
|
||||
return
|
||||
}
|
||||
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/trace-datasources/:id
|
||||
func (h *TraceDSHandler) Delete(c *gin.Context) {
|
||||
if err := h.db.Delete(&model.TraceDataSource{}, c.Param("id")).Error; err != nil {
|
||||
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Message: "删除成功"})
|
||||
}
|
||||
|
||||
// SetDefault POST /api/v1/trace-datasources/:id/default
|
||||
func (h *TraceDSHandler) SetDefault(c *gin.Context) {
|
||||
var ds model.TraceDataSource
|
||||
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.TraceDataSource{}).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/trace-datasources/test
|
||||
func (h *TraceDSHandler) TestConnection(c *gin.Context) {
|
||||
var req model.TestTraceDSReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// 允许传 id 来测试已有数据源
|
||||
id := c.Query("id")
|
||||
if id != "" {
|
||||
var ds model.TraceDataSource
|
||||
if e := h.db.First(&ds, id).Error; e == nil {
|
||||
req.URL = ds.URL
|
||||
req.Type = ds.Type
|
||||
req.BasicAuth = ds.BasicAuth
|
||||
req.BasicAuthUser = ds.BasicAuthUser
|
||||
req.BasicAuthPass = ds.BasicAuthPass
|
||||
req.SkipTLSVerify = ds.SkipTLSVerify
|
||||
}
|
||||
}
|
||||
if req.URL == "" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "url 不能为空"})
|
||||
return
|
||||
}
|
||||
}
|
||||
url := strings.TrimRight(req.URL, "/")
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
if req.SkipTLSVerify {
|
||||
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||
}
|
||||
// Jaeger/Tempo 都有 /api/services 端点
|
||||
target := url + "/api/services"
|
||||
httpReq, _ := http.NewRequest("GET", target, nil)
|
||||
if req.BasicAuth && req.BasicAuthUser != "" {
|
||||
httpReq.SetBasicAuth(req.BasicAuthUser, req.BasicAuthPass)
|
||||
}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
c.JSON(200, model.Response{Code: 200, Data: map[string]interface{}{"ok": false, "error": err.Error()}})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
c.JSON(200, model.Response{Code: 200, Data: map[string]interface{}{"ok": false, "error": "HTTP " + resp.Status}})
|
||||
return
|
||||
}
|
||||
// 尝试解析 JSON 验证有效性
|
||||
var tmp interface{}
|
||||
if json.Unmarshal(body, &tmp) != nil {
|
||||
c.JSON(200, model.Response{Code: 200, Data: map[string]interface{}{"ok": false, "error": "响应非 JSON"}})
|
||||
return
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: map[string]interface{}{"ok": true, "message": "连接成功"}})
|
||||
}
|
||||
|
||||
// getDefaultTraceDS 获取默认链路追踪数据源
|
||||
func (h *TraceDSHandler) getDefaultTraceDS() (*model.TraceDataSource, error) {
|
||||
var ds model.TraceDataSource
|
||||
err := h.db.Where("is_default = ? AND status = 1", true).First(&ds).Error
|
||||
if err != nil {
|
||||
// fallback: 取第一个启用的
|
||||
err = h.db.Where("status = 1").First(&ds).Error
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ds, nil
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TraceProxyHandler 链路追踪查询代理(Jaeger / Tempo 兼容) // Logan.Li
|
||||
// 转发查询请求到外部 Jaeger/Tempo 服务,并将响应转换为前端期望的格式
|
||||
// 无数据源时返回 demo 数据让 UI 保持可用
|
||||
type TraceProxyHandler struct {
|
||||
ds *TraceDSHandler
|
||||
}
|
||||
|
||||
func NewTraceProxyHandler(ds *TraceDSHandler) *TraceProxyHandler {
|
||||
return &TraceProxyHandler{ds: ds}
|
||||
}
|
||||
|
||||
// ---- Jaeger 原始响应结构 ----
|
||||
|
||||
type jaegerServicesResp struct {
|
||||
Data []string `json:"data"`
|
||||
}
|
||||
|
||||
type jaegerTraceResp struct {
|
||||
Data []jaegerTrace `json:"data"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type jaegerTrace struct {
|
||||
TraceID string `json:"traceID"`
|
||||
Spans []jaegerSpan `json:"spans"`
|
||||
Processes map[string]jaegerProc `json:"processes"`
|
||||
}
|
||||
|
||||
type jaegerSpan struct {
|
||||
TraceID string `json:"traceID"`
|
||||
SpanID string `json:"spanID"`
|
||||
OperationName string `json:"operationName"`
|
||||
References []jaegerRef `json:"references"`
|
||||
StartTime int64 `json:"startTime"` // microseconds
|
||||
Duration int64 `json:"duration"` // microseconds
|
||||
Tags []jaegerTag `json:"tags"`
|
||||
ProcessID string `json:"processID"`
|
||||
Logs []jaegerLog `json:"logs"`
|
||||
}
|
||||
|
||||
type jaegerRef struct {
|
||||
RefType string `json:"refType"`
|
||||
TraceID string `json:"traceID"`
|
||||
SpanID string `json:"spanID"`
|
||||
}
|
||||
|
||||
type jaegerProc struct {
|
||||
ServiceName string `json:"serviceName"`
|
||||
Tags []jaegerTag `json:"tags"`
|
||||
}
|
||||
|
||||
type jaegerTag struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
type jaegerLog struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Fields []jaegerTag `json:"fields"`
|
||||
}
|
||||
|
||||
// ---- 前端期望的格式 ----
|
||||
|
||||
type traceListItem struct {
|
||||
ID string `json:"id"`
|
||||
TraceID string `json:"traceId"`
|
||||
ServiceName string `json:"serviceName"`
|
||||
OperationName string `json:"operationName"`
|
||||
StartTime string `json:"startTime"`
|
||||
Duration float64 `json:"duration"` // ms
|
||||
Status string `json:"status"`
|
||||
SpanCount int `json:"spanCount"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
type spanItem struct {
|
||||
ID string `json:"id"`
|
||||
SpanID string `json:"spanId"`
|
||||
ParentSpanID string `json:"parentSpanId,omitempty"`
|
||||
ServiceName string `json:"serviceName"`
|
||||
OperationName string `json:"operationName"`
|
||||
StartTime string `json:"startTime"`
|
||||
Duration float64 `json:"duration"` // ms
|
||||
Status string `json:"status"`
|
||||
Kind string `json:"kind"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
Logs []spanLogItem `json:"logs"`
|
||||
}
|
||||
|
||||
type spanLogItem struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
|
||||
type serviceMetric struct {
|
||||
ServiceName string `json:"serviceName"`
|
||||
RequestCount int `json:"requestCount"`
|
||||
ErrorRate float64 `json:"errorRate"`
|
||||
AvgDuration float64 `json:"avgDuration"` // ms
|
||||
P95Duration float64 `json:"p95Duration"`
|
||||
P99Duration float64 `json:"p99Duration"`
|
||||
}
|
||||
|
||||
// ---- HTTP 辅助 ----
|
||||
|
||||
func newTraceHTTPClient(skipTLS bool, timeout time.Duration) *http.Client {
|
||||
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: skipTLS}}
|
||||
return &http.Client{Transport: tr, Timeout: timeout}
|
||||
}
|
||||
|
||||
func (h *TraceProxyHandler) doGet(ds *model.TraceDataSource, path string) ([]byte, int, error) {
|
||||
target := ds.URL + path
|
||||
client := newTraceHTTPClient(ds.SkipTLSVerify, 30*time.Second)
|
||||
req, _ := http.NewRequest("GET", target, nil)
|
||||
if ds.BasicAuth && ds.BasicAuthUser != "" {
|
||||
req.SetBasicAuth(ds.BasicAuthUser, ds.BasicAuthPass)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// ---- 转换函数 ----
|
||||
|
||||
func tagsToMap(tags []jaegerTag) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, t := range tags {
|
||||
m[t.Key] = fmt.Sprintf("%v", t.Value)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func spanStatus(spans []jaegerSpan) string {
|
||||
for _, s := range spans {
|
||||
for _, t := range s.Tags {
|
||||
if t.Key == "error" {
|
||||
if b, ok := t.Value.(bool); ok && b {
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
if t.Key == "http.status_code" {
|
||||
if code, ok := toInt(t.Value); ok && code >= 400 {
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "success"
|
||||
}
|
||||
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func formatTraceTime(us int64) string {
|
||||
return time.UnixMicro(us).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func kindFromTags(tags []jaegerTag) string {
|
||||
for _, t := range tags {
|
||||
if t.Key == "span.kind" {
|
||||
if s, ok := t.Value.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return "internal"
|
||||
}
|
||||
|
||||
// ---- API: GetServices GET /api/v1/traces/services ----
|
||||
|
||||
func (h *TraceProxyHandler) GetServices(c *gin.Context) {
|
||||
timeRange := c.DefaultQuery("timeRange", "1h")
|
||||
ds, err := h.ds.getDefaultTraceDS()
|
||||
if err != nil || ds == nil {
|
||||
// demo 数据
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
||||
"data": demoServiceMetrics(),
|
||||
"total": 5,
|
||||
"demo": true,
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
body, status, err := h.doGet(ds, "/api/services")
|
||||
if err != nil || status != 200 {
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
||||
"data": demoServiceMetrics(),
|
||||
"total": 5,
|
||||
"demo": true,
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
var svcResp jaegerServicesResp
|
||||
if json.Unmarshal(body, &svcResp) != nil {
|
||||
c.JSON(500, model.Response{Code: 500, Message: "解析服务列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询 traces 来计算服务指标
|
||||
services := buildServiceMetricsFromTraces(h, ds, svcResp.Data, timeRange)
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
||||
"data": services,
|
||||
"total": len(services),
|
||||
}})
|
||||
}
|
||||
|
||||
func buildServiceMetricsFromTraces(h *TraceProxyHandler, ds *model.TraceDataSource, serviceNames []string, timeRange string) []serviceMetric {
|
||||
// 查询最近 traces 来聚合服务指标
|
||||
dur := parseTraceRange(timeRange)
|
||||
end := time.Now()
|
||||
start := end.Add(-dur)
|
||||
traces := h.queryTraces(ds, "", 200, start, end)
|
||||
|
||||
// 按服务聚合
|
||||
type agg struct {
|
||||
durations []float64
|
||||
errors int
|
||||
}
|
||||
aggMap := map[string]*agg{}
|
||||
for _, name := range serviceNames {
|
||||
aggMap[name] = &agg{}
|
||||
}
|
||||
|
||||
for _, t := range traces {
|
||||
for _, sp := range t.Spans {
|
||||
proc, ok := t.Processes[sp.ProcessID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
svcName := proc.ServiceName
|
||||
a, exists := aggMap[svcName]
|
||||
if !exists {
|
||||
a = &agg{}
|
||||
aggMap[svcName] = a
|
||||
}
|
||||
a.durations = append(a.durations, float64(sp.Duration)/1000.0) // μs→ms
|
||||
isErr := false
|
||||
for _, tag := range sp.Tags {
|
||||
if tag.Key == "error" {
|
||||
if b, ok := tag.Value.(bool); ok && b {
|
||||
isErr = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if isErr {
|
||||
a.errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := []serviceMetric{}
|
||||
for _, name := range serviceNames {
|
||||
a := aggMap[name]
|
||||
if len(a.durations) == 0 {
|
||||
result = append(result, serviceMetric{
|
||||
ServiceName: name, RequestCount: 0, ErrorRate: 0,
|
||||
AvgDuration: 0, P95Duration: 0, P99Duration: 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
sort.Float64s(a.durations)
|
||||
total := len(a.durations)
|
||||
sum := 0.0
|
||||
for _, d := range a.durations {
|
||||
sum += d
|
||||
}
|
||||
avg := sum / float64(total)
|
||||
p95 := percentile(a.durations, 0.95)
|
||||
p99 := percentile(a.durations, 0.99)
|
||||
errRate := float64(a.errors) / float64(total) * 100
|
||||
result = append(result, serviceMetric{
|
||||
ServiceName: name,
|
||||
RequestCount: total,
|
||||
ErrorRate: math.Round(errRate*100) / 100,
|
||||
AvgDuration: math.Round(avg*100) / 100,
|
||||
P95Duration: math.Round(p95*100) / 100,
|
||||
P99Duration: math.Round(p99*100) / 100,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func percentile(sorted []float64, p float64) float64 {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
idx := int(float64(len(sorted)-1) * p)
|
||||
return sorted[idx]
|
||||
}
|
||||
|
||||
func parseTraceRange(r string) time.Duration {
|
||||
switch r {
|
||||
case "15m":
|
||||
return 15 * time.Minute
|
||||
case "1h":
|
||||
return time.Hour
|
||||
case "6h":
|
||||
return 6 * time.Hour
|
||||
case "24h":
|
||||
return 24 * time.Hour
|
||||
case "7d":
|
||||
return 7 * 24 * time.Hour
|
||||
default:
|
||||
return time.Hour
|
||||
}
|
||||
}
|
||||
|
||||
// ---- API: GetTraces GET /api/v1/traces ----
|
||||
|
||||
func (h *TraceProxyHandler) GetTraces(c *gin.Context) {
|
||||
service := c.Query("service")
|
||||
status := c.DefaultQuery("status", "all")
|
||||
timeRange := c.DefaultQuery("timeRange", "1h")
|
||||
limit := 50
|
||||
|
||||
ds, err := h.ds.getDefaultTraceDS()
|
||||
if err != nil || ds == nil {
|
||||
// demo 数据
|
||||
demoTraces := demoTraces(service, status)
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
||||
"data": demoTraces,
|
||||
"total": len(demoTraces),
|
||||
"demo": true,
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
dur := parseTraceRange(timeRange)
|
||||
end := time.Now()
|
||||
start := end.Add(-dur)
|
||||
traces := h.queryTraces(ds, service, limit, start, end)
|
||||
|
||||
items := []traceListItem{}
|
||||
for _, t := range traces {
|
||||
if len(t.Spans) == 0 {
|
||||
continue
|
||||
}
|
||||
rootSpan := t.Spans[0]
|
||||
svcName := ""
|
||||
if proc, ok := t.Processes[rootSpan.ProcessID]; ok {
|
||||
svcName = proc.ServiceName
|
||||
}
|
||||
st := spanStatus(t.Spans)
|
||||
// 按状态过滤
|
||||
if status != "all" && st != status {
|
||||
continue
|
||||
}
|
||||
items = append(items, traceListItem{
|
||||
ID: t.TraceID,
|
||||
TraceID: t.TraceID,
|
||||
ServiceName: svcName,
|
||||
OperationName: rootSpan.OperationName,
|
||||
StartTime: formatTraceTime(rootSpan.StartTime),
|
||||
Duration: math.Round(float64(rootSpan.Duration)/1000.0*100) / 100, // μs→ms
|
||||
Status: st,
|
||||
SpanCount: len(t.Spans),
|
||||
Tags: tagsToMap(rootSpan.Tags),
|
||||
})
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
||||
"data": items,
|
||||
"total": len(items),
|
||||
}})
|
||||
}
|
||||
|
||||
// queryTraces 查询 Jaeger traces
|
||||
func (h *TraceProxyHandler) queryTraces(ds *model.TraceDataSource, service string, limit int, start, end time.Time) []jaegerTrace {
|
||||
path := fmt.Sprintf("/api/traces?limit=%d&start=%d&end=%d", limit, start.UnixMicro(), end.UnixMicro())
|
||||
if service != "" && service != "all" {
|
||||
path += "&service=" + service
|
||||
}
|
||||
body, status, err := h.doGet(ds, path)
|
||||
if err != nil || status != 200 {
|
||||
return nil
|
||||
}
|
||||
var resp jaegerTraceResp
|
||||
if json.Unmarshal(body, &resp) != nil {
|
||||
return nil
|
||||
}
|
||||
return resp.Data
|
||||
}
|
||||
|
||||
// ---- API: GetTraceDetail GET /api/v1/traces/:traceId ----
|
||||
|
||||
func (h *TraceProxyHandler) GetTraceDetail(c *gin.Context) {
|
||||
traceID := c.Param("traceId")
|
||||
|
||||
ds, err := h.ds.getDefaultTraceDS()
|
||||
if err != nil || ds == nil {
|
||||
// demo
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
||||
"spans": demoSpans(traceID),
|
||||
"demo": true,
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
body, status, err := h.doGet(ds, "/api/traces/"+traceID)
|
||||
if err != nil || status != 200 {
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{"spans": []spanItem{}}})
|
||||
return
|
||||
}
|
||||
|
||||
var resp jaegerTraceResp
|
||||
if json.Unmarshal(body, &resp) != nil || len(resp.Data) == 0 {
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{"spans": []spanItem{}}})
|
||||
return
|
||||
}
|
||||
|
||||
t := resp.Data[0]
|
||||
spans := []spanItem{}
|
||||
for _, sp := range t.Spans {
|
||||
svcName := ""
|
||||
if proc, ok := t.Processes[sp.ProcessID]; ok {
|
||||
svcName = proc.ServiceName
|
||||
}
|
||||
parentID := ""
|
||||
for _, ref := range sp.References {
|
||||
if ref.RefType == "CHILD_OF" {
|
||||
parentID = ref.SpanID
|
||||
break
|
||||
}
|
||||
}
|
||||
st := "success"
|
||||
for _, tag := range sp.Tags {
|
||||
if tag.Key == "error" {
|
||||
if b, ok := tag.Value.(bool); ok && b {
|
||||
st = "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
logs := []spanLogItem{}
|
||||
for _, l := range sp.Logs {
|
||||
fields := map[string]string{}
|
||||
for _, f := range l.Fields {
|
||||
fields[f.Key] = fmt.Sprintf("%v", f.Value)
|
||||
}
|
||||
logs = append(logs, spanLogItem{
|
||||
Timestamp: formatTraceTime(l.Timestamp),
|
||||
Fields: fields,
|
||||
})
|
||||
}
|
||||
spans = append(spans, spanItem{
|
||||
ID: sp.SpanID,
|
||||
SpanID: sp.SpanID,
|
||||
ParentSpanID: parentID,
|
||||
ServiceName: svcName,
|
||||
OperationName: sp.OperationName,
|
||||
StartTime: formatTraceTime(sp.StartTime),
|
||||
Duration: math.Round(float64(sp.Duration)/1000.0*100) / 100,
|
||||
Status: st,
|
||||
Kind: kindFromTags(sp.Tags),
|
||||
Tags: tagsToMap(sp.Tags),
|
||||
Logs: logs,
|
||||
})
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{"spans": spans}})
|
||||
}
|
||||
|
||||
// ---- Demo 数据 ----
|
||||
|
||||
func demoServiceMetrics() []serviceMetric {
|
||||
return []serviceMetric{
|
||||
{ServiceName: "api-gateway", RequestCount: 15230, ErrorRate: 0.32, AvgDuration: 45.2, P95Duration: 120.5, P99Duration: 250.1},
|
||||
{ServiceName: "user-service", RequestCount: 8421, ErrorRate: 0.15, AvgDuration: 28.7, P95Duration: 75.3, P99Duration: 150.8},
|
||||
{ServiceName: "order-service", RequestCount: 6754, ErrorRate: 1.23, AvgDuration: 89.4, P95Duration: 200.6, P99Duration: 450.2},
|
||||
{ServiceName: "payment-service", RequestCount: 3210, ErrorRate: 0.08, AvgDuration: 156.3, P95Duration: 350.4, P99Duration: 680.7},
|
||||
{ServiceName: "inventory-service", RequestCount: 4567, ErrorRate: 0.45, AvgDuration: 32.1, P95Duration: 85.7, P99Duration: 170.3},
|
||||
}
|
||||
}
|
||||
|
||||
func demoTraces(serviceFilter, statusFilter string) []traceListItem {
|
||||
now := time.Now()
|
||||
all := []traceListItem{
|
||||
{ID: "trace-001", TraceID: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", ServiceName: "api-gateway", OperationName: "GET /api/v1/orders", StartTime: now.Add(-2 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 125.4, Status: "success", SpanCount: 8, Tags: map[string]string{"http.method": "GET", "http.status_code": "200"}},
|
||||
{ID: "trace-002", TraceID: "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", ServiceName: "user-service", OperationName: "POST /api/v1/users/login", StartTime: now.Add(-5 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 45.7, Status: "success", SpanCount: 4, Tags: map[string]string{"http.method": "POST", "http.status_code": "200"}},
|
||||
{ID: "trace-003", TraceID: "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6", ServiceName: "order-service", OperationName: "POST /api/v1/orders", StartTime: now.Add(-8 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 380.2, Status: "error", SpanCount: 12, Tags: map[string]string{"http.method": "POST", "http.status_code": "500", "error": "true"}},
|
||||
{ID: "trace-004", TraceID: "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", ServiceName: "payment-service", OperationName: "POST /api/v1/payments", StartTime: now.Add(-12 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 220.8, Status: "success", SpanCount: 6, Tags: map[string]string{"http.method": "POST", "http.status_code": "200"}},
|
||||
{ID: "trace-005", TraceID: "e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", ServiceName: "inventory-service", OperationName: "GET /api/v1/inventory/:id", StartTime: now.Add(-15 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 32.1, Status: "success", SpanCount: 3, Tags: map[string]string{"http.method": "GET", "http.status_code": "200"}},
|
||||
{ID: "trace-006", TraceID: "f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", ServiceName: "api-gateway", OperationName: "GET /api/v1/products", StartTime: now.Add(-20 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 95.3, Status: "success", SpanCount: 5, Tags: map[string]string{"http.method": "GET", "http.status_code": "200"}},
|
||||
}
|
||||
result := []traceListItem{}
|
||||
for _, t := range all {
|
||||
if serviceFilter != "" && serviceFilter != "all" && t.ServiceName != serviceFilter {
|
||||
continue
|
||||
}
|
||||
if statusFilter != "all" && t.Status != statusFilter {
|
||||
continue
|
||||
}
|
||||
result = append(result, t)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func demoSpans(traceID string) []spanItem {
|
||||
now := time.Now().Add(-2 * time.Minute)
|
||||
return []spanItem{
|
||||
{ID: "span-1", SpanID: "span-1", ServiceName: "api-gateway", OperationName: "GET /api/v1/orders", StartTime: now.Format("2006-01-02 15:04:05"), Duration: 125.4, Status: "success", Kind: "server", Tags: map[string]string{"http.method": "GET", "http.url": "/api/v1/orders"}, Logs: []spanLogItem{}},
|
||||
{ID: "span-2", SpanID: "span-2", ParentSpanID: "span-1", ServiceName: "user-service", OperationName: "auth.check", StartTime: now.Add(2 * time.Millisecond).Format("2006-01-02 15:04:05"), Duration: 15.2, Status: "success", Kind: "client", Tags: map[string]string{"rpc.system": "grpc"}, Logs: []spanLogItem{}},
|
||||
{ID: "span-3", SpanID: "span-3", ParentSpanID: "span-1", ServiceName: "order-service", OperationName: "order.list", StartTime: now.Add(20 * time.Millisecond).Format("2006-01-02 15:04:05"), Duration: 65.8, Status: "success", Kind: "client", Tags: map[string]string{"db.system": "mysql", "db.statement": "SELECT"}, Logs: []spanLogItem{}},
|
||||
{ID: "span-4", SpanID: "span-4", ParentSpanID: "span-3", ServiceName: "inventory-service", OperationName: "inventory.check", StartTime: now.Add(30 * time.Millisecond).Format("2006-01-02 15:04:05"), Duration: 22.1, Status: "success", Kind: "client", Tags: map[string]string{"rpc.system": "grpc"}, Logs: []spanLogItem{}},
|
||||
{ID: "span-5", SpanID: "span-5", ParentSpanID: "span-3", ServiceName: "order-service", OperationName: "db.query", StartTime: now.Add(50 * time.Millisecond).Format("2006-01-02 15:04:05"), Duration: 10.5, Status: "success", Kind: "internal", Tags: map[string]string{"db.system": "mysql"}, Logs: []spanLogItem{}},
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 strings 包被使用(避免 unused import 在某些构建条件下)
|
||||
var _ = strings.TrimSpace
|
||||
@@ -60,9 +60,10 @@ type AlertRule struct {
|
||||
Name string `gorm:"type:varchar(128);not null;comment:规则名称" json:"name"`
|
||||
Description string `gorm:"type:varchar(500);comment:规则描述" json:"description,omitempty"`
|
||||
// ====== 原 V1 兼容字段 ======
|
||||
Type string `gorm:"type:varchar(32);comment:规则类型: metric/log/event" json:"type,omitempty"`
|
||||
Source string `gorm:"type:varchar(64);comment:数据源: prometheus/zabbix/custom" json:"source,omitempty"`
|
||||
Expression string `gorm:"type:text;comment:PromQL/Zabbix表达式" json:"expression,omitempty"`
|
||||
Type string `gorm:"type:varchar(32);comment:规则类型: metric/log/event" json:"type,omitempty"`
|
||||
Source string `gorm:"type:varchar(64);comment:数据源: prometheus/zabbix/custom/loki/elasticsearch" json:"source,omitempty"`
|
||||
Expression string `gorm:"type:text;comment:PromQL/Zabbix/LogQL/Lucene表达式" json:"expression,omitempty"`
|
||||
DatasourceID uint `gorm:"index;comment:日志/指标数据源ID" json:"datasource_id,omitempty"`
|
||||
Severity string `gorm:"type:varchar(32);default:'warning';comment:告警级别" json:"severity"`
|
||||
Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"`
|
||||
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
|
||||
@@ -360,6 +361,7 @@ type CreateAlertRuleRequest struct {
|
||||
Expression string `json:"expression" binding:"required"`
|
||||
Severity string `json:"severity" binding:"omitempty,oneof=info warning critical"`
|
||||
Duration int `json:"duration"`
|
||||
DatasourceID uint `json:"datasource_id"`
|
||||
Labels string `json:"labels"`
|
||||
Annotations string `json:"annotations"`
|
||||
NotifyChannels []string `json:"notify_channels"`
|
||||
@@ -372,6 +374,7 @@ type UpdateAlertRuleRequest struct {
|
||||
Expression string `json:"expression"`
|
||||
Severity string `json:"severity" binding:"omitempty,oneof=info warning critical"`
|
||||
Duration int `json:"duration"`
|
||||
DatasourceID uint `json:"datasource_id"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Labels string `json:"labels"`
|
||||
Annotations string `json:"annotations"`
|
||||
|
||||
@@ -55,9 +55,13 @@ type UpdateLogDSReq struct {
|
||||
}
|
||||
|
||||
// TestLogDSReq 测试连接 // Logan.Li
|
||||
// 支持两种模式:
|
||||
// 1. 新建/未保存场景:必须传 type + url + 认证参数;
|
||||
// 2. 已保存数据源场景:传 datasource_id,后端读取完整配置(含密码)再测试。// Logan.Li
|
||||
type TestLogDSReq struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
DatasourceID uint `json:"datasource_id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
BasicAuth bool `json:"basic_auth"`
|
||||
BasicAuthUser string `json:"basic_auth_user"`
|
||||
BasicAuthPass string `json:"basic_auth_pass"`
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// TraceDataSource 链路追踪数据源(Jaeger / Tempo) // Logan.Li
|
||||
type TraceDataSource struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
|
||||
Name string `gorm:"type:varchar(128);not null;uniqueIndex;comment:数据源名称" json:"name"`
|
||||
Type string `gorm:"type:varchar(32);not null;index;comment:类型 jaeger/tempo" json:"type"`
|
||||
URL string `gorm:"type:varchar(512);not null;comment:服务地址" json:"url"`
|
||||
BasicAuth bool `gorm:"default:false;comment:基础认证" json:"basic_auth"`
|
||||
BasicAuthUser string `gorm:"type:varchar(128);comment:认证用户名" json:"basic_auth_user,omitempty"`
|
||||
BasicAuthPass string `gorm:"type:varchar(256);comment:认证密码" json:"basic_auth_pass,omitempty"`
|
||||
SkipTLSVerify bool `gorm:"default:false;comment:跳过TLS验证" json:"skip_tls_verify"`
|
||||
IsDefault bool `gorm:"default:false;index;comment:是否默认" json:"is_default"`
|
||||
Status int `gorm:"default:1;comment:状态 1启用 0禁用" json:"status"`
|
||||
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
|
||||
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
func (TraceDataSource) TableName() string {
|
||||
return "monitor_trace_datasources"
|
||||
}
|
||||
|
||||
// CreateTraceDSReq 创建链路追踪数据源请求
|
||||
type CreateTraceDSReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
BasicAuth bool `json:"basic_auth"`
|
||||
BasicAuthUser string `json:"basic_auth_user"`
|
||||
BasicAuthPass string `json:"basic_auth_pass"`
|
||||
SkipTLSVerify bool `json:"skip_tls_verify"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// UpdateTraceDSReq 更新链路追踪数据源请求(partial update)
|
||||
type UpdateTraceDSReq struct {
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
URL *string `json:"url"`
|
||||
BasicAuth *bool `json:"basic_auth"`
|
||||
BasicAuthUser *string `json:"basic_auth_user"`
|
||||
BasicAuthPass *string `json:"basic_auth_pass"`
|
||||
SkipTLSVerify *bool `json:"skip_tls_verify"`
|
||||
IsDefault *bool `json:"is_default"`
|
||||
Status *int `json:"status"`
|
||||
}
|
||||
|
||||
// TestTraceDSReq 测试连接请求
|
||||
type TestTraceDSReq struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Type string `json:"type"`
|
||||
BasicAuth bool `json:"basic_auth"`
|
||||
BasicAuthUser string `json:"basic_auth_user"`
|
||||
BasicAuthPass string `json:"basic_auth_pass"`
|
||||
SkipTLSVerify bool `json:"skip_tls_verify"`
|
||||
}
|
||||
@@ -157,6 +157,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li
|
||||
&model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li
|
||||
&model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li
|
||||
&model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
|
||||
@@ -978,6 +978,24 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
customDashGroup.POST("/:id/default", customDashHandler.SetDefault)
|
||||
}
|
||||
|
||||
// 链路追踪数据源管理 — CRUD + 测试连接 + 设默认 // Logan.Li
|
||||
traceDSHandler := controller.NewTraceDSHandler(db)
|
||||
traceProxyHandler := controller.NewTraceProxyHandler(traceDSHandler)
|
||||
traceDSGroup := authorized.Group("/trace-datasources")
|
||||
{
|
||||
traceDSGroup.GET("", traceDSHandler.List)
|
||||
traceDSGroup.GET("/:id", traceDSHandler.Get)
|
||||
traceDSGroup.POST("", traceDSHandler.Create)
|
||||
traceDSGroup.PUT("/:id", traceDSHandler.Update)
|
||||
traceDSGroup.DELETE("/:id", traceDSHandler.Delete)
|
||||
traceDSGroup.POST("/:id/default", traceDSHandler.SetDefault)
|
||||
traceDSGroup.POST("/test", traceDSHandler.TestConnection)
|
||||
}
|
||||
// 链路追踪查询代理 — 转发 Jaeger/Tempo API
|
||||
authorized.GET("/traces", traceProxyHandler.GetTraces)
|
||||
authorized.GET("/traces/services", traceProxyHandler.GetServices)
|
||||
authorized.GET("/traces/:traceId", traceProxyHandler.GetTraceDetail)
|
||||
|
||||
// 凭据管理 — SSH密钥/API Token/密码统一加密管理 // Logan.Li
|
||||
credentialHandlerV2 := controller.NewCredentialHandlerV2(svcs.Credential)
|
||||
credentialHandler := controller.NewCredentialHandler(db, cfg.JWT.Secret) // 仅用于 GetTags/PatchCredential/AssignCredential(待拆分)
|
||||
|
||||
Reference in new issue
Block a user