feat(logs): metrics 指标代理端点(admin) + 快捷查询选中 App/Pod 即插入并执行
- POST /logs/metrics:LogQL 指标查询代理,[$step] 占位符按跨度自适应步长,返回多序列(httptest 假 Loki 单测) - 快捷查询:选中 App/Pod 即写入输入框并立即查询,面板保持打开支持 Pod 级联;命名空间仅级联筛选不插入,切换时清理失效标签 - 删除冗余的「查询该范围日志」「指定副本日志」按钮;Ingress 按钮仅在选择 ingress 命名空间/app 时显示 - /logs 支持 ?q= 预填自动执行(表格「查看日志」跳转入口) - 清理无引用死组件 LogResults/LogHistogram
This commit is contained in:
1 parent
5ce90fe2e5
commit
16306a9532
10 files changed
+264
-295
No files matched your search
@@ -317,3 +317,27 @@ func (ctrl *LogsController) Histogram(c *gin.Context) {
|
||||
}
|
||||
utils.Success(c, buckets)
|
||||
}
|
||||
|
||||
// Metrics 日志分析指标查询(仅 admin,路由层 AdminAuth 拦截)
|
||||
func (ctrl *LogsController) Metrics(c *gin.Context) {
|
||||
var req dto.LogMetricQueryRequest
|
||||
if !utils.BindAndValidate(c, &req) {
|
||||
return
|
||||
}
|
||||
src := services.GetLogSource(req.SourceID)
|
||||
if src == nil {
|
||||
utils.BadRequest(c, "数据源不存在")
|
||||
return
|
||||
}
|
||||
query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", req.Query)
|
||||
if err != nil {
|
||||
utils.Forbidden(c, err.Error())
|
||||
return
|
||||
}
|
||||
series, err := services.LokiMetrics(c.Request.Context(), src, query, req.Start, req.End)
|
||||
if err != nil {
|
||||
utils.Error(c, 502, err.Error())
|
||||
return
|
||||
}
|
||||
utils.Success(c, series)
|
||||
}
|
||||
@@ -32,6 +32,15 @@ type LogQueryRequest struct {
|
||||
Cursor string `json:"cursor"`
|
||||
}
|
||||
|
||||
// LogMetricQueryRequest 日志分析指标查询:Query 为完整 LogQL 指标表达式,
|
||||
// 其中的 [$step] 占位符由服务端按时间跨度替换为自适应步长(与直方图同款)
|
||||
type LogMetricQueryRequest struct {
|
||||
SourceID uint `json:"sourceId" binding:"required"`
|
||||
Query string `json:"query" binding:"required"`
|
||||
Start int64 `json:"start"` // epoch 纳秒
|
||||
End int64 `json:"end"`
|
||||
}
|
||||
|
||||
type LogContextRequest struct {
|
||||
SourceID uint `json:"sourceId" binding:"required"`
|
||||
TimestampNs string `json:"timestampNs" binding:"required"`
|
||||
|
||||
@@ -91,6 +91,7 @@ func SetupRouter() *gin.Engine {
|
||||
logs.GET("/label/:name/values", logsCtrl.LabelValues)
|
||||
logs.GET("/my-scope", logsCtrl.MyScope)
|
||||
logs.POST("/histogram", logsCtrl.Histogram)
|
||||
logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics)
|
||||
logs.GET("/health", logsCtrl.Health)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
// TestLokiMetricsParsesMatrixSeries 用假 Loki 验证指标代理:
|
||||
// [$step] 占位符替换 + matrix 多序列解析(按 app/namespace 分组)。
|
||||
func TestLokiMetricsParsesMatrixSeries(t *testing.T) {
|
||||
var gotQuery, gotStep string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.Query().Get("query")
|
||||
gotStep = r.URL.Query().Get("step")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"resultType": "matrix",
|
||||
"result": [
|
||||
{
|
||||
"metric": {"app": "web", "namespace": "prod"},
|
||||
"values": [[1.79e+09, "10"], [1.79e+09, "20"]]
|
||||
},
|
||||
{
|
||||
"metric": {"app": "api", "namespace": "prod"},
|
||||
"values": [[1.79e+09, "5"]]
|
||||
},
|
||||
{
|
||||
"metric": {"app": "empty", "namespace": "prod"},
|
||||
"values": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
src := &models.LogSource{TargetType: "url", URL: server.URL, AuthType: "none"}
|
||||
query := `sum by (app, namespace) (count_over_time({namespace=~".+"}[$step]))`
|
||||
end := time.Now().UnixNano()
|
||||
start := end - int64(time.Hour)
|
||||
|
||||
series, err := LokiMetrics(context.Background(), src, query, start, end)
|
||||
if err != nil {
|
||||
t.Fatalf("LokiMetrics: %v", err)
|
||||
}
|
||||
|
||||
// 占位符被替换为 1h 跨度对应的步长,且 query_range 带上了同样的 step
|
||||
if gotQuery == query || !strings.Contains(gotQuery, `count_over_time({namespace=~".+"}[`) {
|
||||
t.Fatalf("[$step] not replaced, got %q", gotQuery)
|
||||
}
|
||||
if gotStep == "" || !strings.Contains(gotQuery, "["+gotStep+"]") {
|
||||
t.Fatalf("step mismatch: query=%q step=%q", gotQuery, gotStep)
|
||||
}
|
||||
|
||||
// 空序列被丢弃,两条有效序列带标签返回
|
||||
if len(series) != 2 {
|
||||
t.Fatalf("want 2 series, got %d", len(series))
|
||||
}
|
||||
if series[0].Labels["app"] != "web" || series[0].Labels["namespace"] != "prod" {
|
||||
t.Fatalf("labels not parsed: %+v", series[0].Labels)
|
||||
}
|
||||
if len(series[0].Points) != 2 || series[0].Points[0].Value != 10 || series[0].Points[1].Value != 20 {
|
||||
t.Fatalf("points not parsed: %+v", series[0].Points)
|
||||
}
|
||||
if series[0].Points[0].Timestamp == "" {
|
||||
t.Fatal("timestamp not formatted")
|
||||
}
|
||||
|
||||
// 响应可 JSON 序列化(controller 直接 utils.Success 包装)
|
||||
if _, err := json.Marshal(series); err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"k8s.io/client-go/rest"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
const maxLogQueryLimit = 1000
|
||||
@@ -449,6 +449,71 @@ func LokiHistogram(ctx context.Context, src *models.LogSource, query string, sta
|
||||
return buckets, nil
|
||||
}
|
||||
|
||||
// MetricSeriesPoint 指标序列上的一个采样点
|
||||
type MetricSeriesPoint struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// MetricSeries 一条按标签分组的指标序列(如 sum by (app)(...) 的每个 app 一条)
|
||||
type MetricSeries struct {
|
||||
Labels map[string]string `json:"labels"`
|
||||
Points []MetricSeriesPoint `json:"points"`
|
||||
}
|
||||
|
||||
// LokiMetrics 通用 LogQL 指标查询(日志分析页用):query 为完整指标表达式,
|
||||
// 其中所有 [$step] 占位符替换为按时间跨度自适应的步长后走 query_range,
|
||||
// 返回矩阵多序列(直方图只取单序列,这里按 by (...) 分组整表返回)
|
||||
func LokiMetrics(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]MetricSeries, error) {
|
||||
if end <= 0 {
|
||||
end = time.Now().UnixNano()
|
||||
}
|
||||
if start <= 0 {
|
||||
start = end - int64(time.Hour)
|
||||
}
|
||||
step := pickHistogramStep(end - start)
|
||||
wrapped := strings.ReplaceAll(query, "[$step]", "["+step+"]")
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("query", wrapped)
|
||||
q.Set("start", strconv.FormatInt(start, 10))
|
||||
q.Set("end", strconv.FormatInt(end, 10))
|
||||
q.Set("step", step)
|
||||
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query_range", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Result []struct {
|
||||
Metric map[string]string `json:"metric"`
|
||||
Values [][2]interface{} `json:"values"`
|
||||
} `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
series := make([]MetricSeries, 0, len(resp.Data.Result))
|
||||
for _, item := range resp.Data.Result {
|
||||
points := make([]MetricSeriesPoint, 0, len(item.Values))
|
||||
for _, v := range item.Values {
|
||||
// metric API 的 ts 为秒级浮点数(如 1.79e+09)
|
||||
tsf, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[0]), 64)
|
||||
cnt, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[1]), 64)
|
||||
points = append(points, MetricSeriesPoint{
|
||||
Timestamp: time.Unix(int64(tsf), 0).Format(time.RFC3339),
|
||||
Value: cnt,
|
||||
})
|
||||
}
|
||||
if len(points) == 0 {
|
||||
continue
|
||||
}
|
||||
series = append(series, MetricSeries{Labels: item.Metric, Points: points})
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// pickHistogramStep 按时间跨度自适应步长(aiops 同款)
|
||||
func pickHistogramStep(spanNano int64) string {
|
||||
span := time.Duration(spanNano)
|
||||
|
||||
Reference in new issue
Block a user