Files
aiops/backend/controller/log_proxy_explore.go
T
Logan 7e1c8f583c feat: 工具配置增强、站点拨测与日志中心多项改进
- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖
- 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败
- Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS
- 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率
- 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示
- 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
2026-07-02 07:47:33 +08:00

480 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"ezops/middleware"
"ezops/model"
"ezops/pkg/logaccess"
"github.com/gin-gonic/gin"
)
// 探索类 API(标签浏览 / 直方图)与 log_proxy.go 查询代理拆分 // Logan.Li
// ListLokiLabels GET /api/v1/logs/loki/labels?datasource_id=&start=&end=
func (h *LogProxyHandler) ListLokiLabels(c *gin.Context) {
dsID, _ := strconv.ParseUint(c.Query("datasource_id"), 10, 64)
if dsID == 0 {
c.JSON(400, model.Response{Code: 400, Message: "datasource_id 必填"})
return
}
ds, err := loadLogDS(h.ds, uint(dsID))
if err != nil || ds.Type != "loki" {
c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"})
return
}
start, end := parseLogTimeRange(c.Query("start"), c.Query("end"))
params := url.Values{}
params.Set("start", fmt.Sprintf("%d", start.UnixNano()))
params.Set("end", fmt.Sprintf("%d", end.UnixNano()))
client := newLogHTTPClient(ds.SkipTLSVerify, 15*time.Second)
target := strings.TrimRight(ds.URL, "/") + "/loki/api/v1/labels?" + params.Encode()
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
applyLogDSAuthV2(httpReq, ds)
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(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "Loki 错误: " + string(body)})
return
}
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
labels := extractStringSlice(raw["data"])
c.JSON(200, model.Response{Code: 200, Data: labels})
}
// ListLokiLabelValues GET /api/v1/logs/loki/labels/:name/values
func (h *LogProxyHandler) ListLokiLabelValues(c *gin.Context) {
name := c.Param("name")
dsID, _ := strconv.ParseUint(c.Query("datasource_id"), 10, 64)
if dsID == 0 || name == "" {
c.JSON(400, model.Response{Code: 400, Message: "datasource_id 与 label 名必填"})
return
}
ds, err := loadLogDS(h.ds, uint(dsID))
if err != nil || ds.Type != "loki" {
c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"})
return
}
start, end := parseLogTimeRange(c.Query("start"), c.Query("end"))
params := url.Values{}
params.Set("start", fmt.Sprintf("%d", start.UnixNano()))
params.Set("end", fmt.Sprintf("%d", end.UnixNano()))
if q := extractLokiStreamSelector(strings.TrimSpace(c.Query("query"))); q != "" {
params.Set("query", q)
}
client := newLogHTTPClient(ds.SkipTLSVerify, 15*time.Second)
target := fmt.Sprintf("%s/loki/api/v1/label/%s/values?%s", strings.TrimRight(ds.URL, "/"), url.PathEscape(name), params.Encode())
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
applyLogDSAuthV2(httpReq, ds)
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(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "Loki 错误: " + string(body)})
return
}
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
values := extractStringSlice(raw["data"])
c.JSON(200, model.Response{Code: 200, Data: values})
}
// LokiHistogram POST /api/v1/logs/loki/histogram — 服务端日志量直方图 // Logan.Li
func (h *LogProxyHandler) LokiHistogram(c *gin.Context) {
var req struct {
DatasourceID uint `json:"datasource_id" binding:"required"`
Query string `json:"query" binding:"required"`
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
}
allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{
Engine: "loki", DatasourceID: req.DatasourceID, Query: req.Query,
})
if !allowed {
c.JSON(403, model.Response{Code: 403, Message: denyMsg})
return
}
ds, err := loadLogDS(h.ds, req.DatasourceID)
if err != nil || ds.Type != "loki" {
c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"})
return
}
start, end := parseLogTimeRange(req.Start, req.End)
step := pickHistogramStep(end.Sub(start))
metricQuery := fmt.Sprintf("sum(count_over_time(%s[%s]))", req.Query, step)
params := url.Values{}
params.Set("query", metricQuery)
params.Set("start", fmt.Sprintf("%d", start.UnixNano()))
params.Set("end", fmt.Sprintf("%d", end.UnixNano()))
params.Set("step", step)
client := newLogHTTPClient(ds.SkipTLSVerify, 30*time.Second)
target := strings.TrimRight(ds.URL, "/") + "/loki/api/v1/query_range?" + params.Encode()
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
applyLogDSAuthV2(httpReq, ds)
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(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "Loki 错误: " + string(body)})
return
}
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
buckets := flattenLokiMetricSeries(raw)
c.JSON(200, model.Response{Code: 200, Data: gin.H{"buckets": buckets}})
}
// ESHistogram POST /api/v1/logs/elasticsearch/histogram — ES date_histogram 聚合 // Logan.Li
func (h *LogProxyHandler) ESHistogram(c *gin.Context) {
var req struct {
DatasourceID uint `json:"datasource_id" binding:"required"`
Index string `json:"index"`
Query string `json:"query"`
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
}
index := req.Index
if index == "" {
if dsPreview, err := loadLogDS(h.ds, req.DatasourceID); err == nil {
index = dsPreview.DefaultIndex
}
}
if index == "" {
index = "*"
}
allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{
Engine: "elasticsearch", DatasourceID: req.DatasourceID, Index: index, Query: req.Query,
})
if !allowed {
c.JSON(403, model.Response{Code: 403, Message: denyMsg})
return
}
ds, err := loadLogDS(h.ds, req.DatasourceID)
if err != nil || ds.Type != "elasticsearch" {
c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"})
return
}
start, end := parseLogTimeRange(req.Start, req.End)
interval := pickHistogramStep(end.Sub(start))
searchBody := buildESHistogramBody(req.Query, start, end, interval)
payload, _ := json.Marshal(searchBody)
client := newLogHTTPClient(ds.SkipTLSVerify, 30*time.Second)
target := fmt.Sprintf("%s/%s/_search", strings.TrimRight(ds.URL, "/"), strings.Trim(index, "/"))
httpReq, _ := http.NewRequest(http.MethodPost, target, bytes.NewReader(payload))
httpReq.Header.Set("Content-Type", "application/json")
applyLogDSAuthV2(httpReq, ds)
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(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "ES 错误: " + string(body)})
return
}
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
buckets := flattenESHistogram(raw)
c.JSON(200, model.Response{Code: 200, Data: gin.H{"buckets": buckets}})
}
func parseLogTimeRange(startStr, endStr string) (time.Time, time.Time) {
end := time.Now()
start := end.Add(-1 * time.Hour)
if endStr != "" {
if t, e := parseLogTime(endStr); e == nil {
end = t
}
}
if startStr != "" {
if t, e := parseLogTime(startStr); e == nil {
start = t
}
}
return start, end
}
func pickHistogramStep(d time.Duration) string {
switch {
case d <= 15*time.Minute:
return "30s"
case d <= time.Hour:
return "1m"
case d <= 6*time.Hour:
return "5m"
case d <= 24*time.Hour:
return "15m"
case d <= 7*24*time.Hour:
return "1h"
default:
return "6h"
}
}
func extractStringSlice(v interface{}) []string {
arr, _ := v.([]interface{})
out := make([]string, 0, len(arr))
for _, item := range arr {
out = append(out, fmt.Sprintf("%v", item))
}
return out
}
func flattenLokiMetricSeries(raw map[string]interface{}) []map[string]interface{} {
out := []map[string]interface{}{}
data, _ := raw["data"].(map[string]interface{})
result, _ := data["result"].([]interface{})
for _, item := range result {
series, _ := item.(map[string]interface{})
values, _ := series["values"].([]interface{})
for _, val := range values {
pair, _ := val.([]interface{})
if len(pair) < 2 {
continue
}
ts := formatLokiMetricTimestamp(pair[0])
cnt, _ := strconv.ParseFloat(fmt.Sprintf("%v", pair[1]), 64)
out = append(out, map[string]interface{}{
"timestamp": ts,
"count": int64(cnt),
})
}
}
return out
}
func buildESHistogramBody(query string, start, end time.Time, interval string) map[string]interface{} {
body := map[string]interface{}{
"size": 0,
"query": map[string]interface{}{
"bool": map[string]interface{}{
"must": []interface{}{
map[string]interface{}{"query_string": map[string]interface{}{"query": defaultESQuery(query)}},
},
},
},
"aggs": map[string]interface{}{
"log_volume": map[string]interface{}{
"date_histogram": map[string]interface{}{
"field": "@timestamp",
"fixed_interval": interval,
"min_doc_count": 0,
},
},
},
}
rangeCond := map[string]interface{}{
"bool": map[string]interface{}{
"should": []interface{}{
map[string]interface{}{"range": map[string]interface{}{"@timestamp": map[string]string{
"gte": start.UTC().Format(time.RFC3339Nano),
"lte": end.UTC().Format(time.RFC3339Nano),
}}},
map[string]interface{}{"range": map[string]interface{}{"timestamp": map[string]string{
"gte": start.UTC().Format(time.RFC3339Nano),
"lte": end.UTC().Format(time.RFC3339Nano),
}}},
},
"minimum_should_match": 1,
},
}
body["query"] = map[string]interface{}{
"bool": map[string]interface{}{
"must": []interface{}{body["query"], rangeCond},
},
}
return body
}
func flattenESHistogram(raw map[string]interface{}) []map[string]interface{} {
out := []map[string]interface{}{}
aggs, _ := raw["aggregations"].(map[string]interface{})
vol, _ := aggs["log_volume"].(map[string]interface{})
buckets, _ := vol["buckets"].([]interface{})
for _, b := range buckets {
bucket, _ := b.(map[string]interface{})
ts, _ := bucket["key_as_string"].(string)
if ts == "" {
if key, ok := bucket["key"].(float64); ok {
ts = time.UnixMilli(int64(key)).UTC().Format(time.RFC3339Nano)
}
}
cnt, _ := bucket["doc_count"].(float64)
out = append(out, map[string]interface{}{"timestamp": ts, "count": int64(cnt)})
}
return out
}
// ESMapping GET /api/v1/logs/elasticsearch/mapping?datasource_id=&index=
func (h *LogProxyHandler) ESMapping(c *gin.Context) {
dsID, _ := strconv.ParseUint(c.Query("datasource_id"), 10, 64)
index := strings.TrimSpace(c.Query("index"))
if dsID == 0 {
c.JSON(400, model.Response{Code: 400, Message: "datasource_id 必填"})
return
}
if index == "" {
index = "*"
}
allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{
Engine: "elasticsearch", DatasourceID: uint(dsID), Index: index, Query: "*",
})
if !allowed {
c.JSON(403, model.Response{Code: 403, Message: denyMsg})
return
}
ds, err := loadLogDS(h.ds, uint(dsID))
if err != nil || ds.Type != "elasticsearch" {
c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"})
return
}
client := newLogHTTPClient(ds.SkipTLSVerify, 20*time.Second)
target := fmt.Sprintf("%s/%s/_mapping", strings.TrimRight(ds.URL, "/"), strings.Trim(index, "/"))
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
applyLogDSAuthV2(httpReq, ds)
resp, err := client.Do(httpReq)
if err != nil {
c.JSON(502, model.Response{Code: 502, Message: "获取 mapping 失败: " + err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "ES 错误: " + string(body)})
return
}
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
fields := flattenESMapping(raw)
c.JSON(200, model.Response{Code: 200, Data: fields})
}
func flattenESMapping(raw map[string]interface{}) []map[string]string {
out := []map[string]string{}
seen := map[string]bool{}
var walk func(prefix string, props map[string]interface{})
walk = func(prefix string, props map[string]interface{}) {
for name, node := range props {
full := name
if prefix != "" {
full = prefix + "." + name
}
m, _ := node.(map[string]interface{})
if m == nil {
continue
}
if sub, ok := m["properties"].(map[string]interface{}); ok {
walk(full, sub)
continue
}
if fields, ok := m["fields"].(map[string]interface{}); ok {
walk(full, fields)
}
typ, _ := m["type"].(string)
if typ == "" {
typ = "object"
}
if !seen[full] {
seen[full] = true
out = append(out, map[string]string{"name": full, "type": mapESFieldType(typ)})
}
}
}
for _, idxDef := range raw {
idxMap, _ := idxDef.(map[string]interface{})
mappings, _ := idxMap["mappings"].(map[string]interface{})
props, _ := mappings["properties"].(map[string]interface{})
if props != nil {
walk("", props)
}
}
return out
}
func mapESFieldType(esType string) string {
switch esType {
case "keyword", "text", "match_only_text":
return "keyword"
case "long", "integer", "short", "byte", "double", "float", "half_float", "scaled_float":
return "long"
case "date", "date_nanos":
return "date"
case "boolean":
return "boolean"
case "ip":
return "ip"
default:
return "text"
}
}
// extractLokiStreamSelector 从 LogQL 提取 {...} 选择器(Label API 仅接受 stream selector)// Logan.Li
func extractLokiStreamSelector(query string) string {
query = strings.TrimSpace(query)
start := strings.Index(query, "{")
if start < 0 {
return ""
}
depth := 0
for i := start; i < len(query); i++ {
switch query[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return query[start : i+1]
}
}
}
return ""
}
// formatLokiMetricTimestamp 解析 Loki metric 序列时间戳(秒级 float / 科学计数法)// Logan.Li
func formatLokiMetricTimestamp(v interface{}) string {
switch t := v.(type) {
case float64:
sec := int64(t)
nsec := int64((t - float64(sec)) * 1e9)
return time.Unix(sec, nsec).UTC().Format(time.RFC3339)
case json.Number:
if f, err := t.Float64(); err == nil {
sec := int64(f)
return time.Unix(sec, 0).UTC().Format(time.RFC3339)
}
}
return formatLokiTimestamp(fmt.Sprintf("%v", v))
}