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:
attacker committed 2026-06-28 01:51:00 +08:00
1 parent 0c2c0adaaf
commit 894cb16a05
23 files changed
+1085 -52

No files matched your search

+11 -1
View File
@@ -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()
}
+4
View File
@@ -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
}
+21 -1
View File
@@ -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"}})
+221
View File
@@ -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
}
+540
View File
@@ -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
+6 -3
View File
@@ -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"`
+6 -2
View File
@@ -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"`
+59
View File
@@ -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"`
}
+1
View File
@@ -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
+18
View File
@@ -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(待拆分)