Files
aiops/backend/controller/log_proxy.go
T
attacker 7af10097c9 fix(log): input text visibility + space-in-date 502
- UnifiedQueryPanel: fix JS operator precedence — .replace(' ','T') was
  called on '.000Z' suffix instead of concatenated string, resulting in
  '2026-06-27 12:46:28.000Z' (space not replaced) → ES 502
- normalizeTime: add space→T fallback for backward compat
- SyntaxHighlightInput: fix z-index layering (textarea z=2 was covering
  pre z=1, making highlighted text invisible). Move bg/border to parent,
  textarea bg→transparent, pre above textarea
2026-06-27 12:54:31 +00:00

329 lines
9.2 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/model"
"github.com/gin-gonic/gin"
)
// LogProxyHandler Loki / ES 无状态查询代理
// 实时转发请求并解析展示字段,不将日志写入 AIOps 数据库 // Logan.Li
type LogProxyHandler struct {
ds *LogDSHandler
}
const maxLogQueryLimit = 500
func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler {
return &LogProxyHandler{ds: ds}
}
// QueryLoki POST /api/v1/logs/loki/query
func (h *LogProxyHandler) QueryLoki(c *gin.Context) {
var req struct {
DatasourceID uint `json:"datasource_id" binding:"required"`
Query string `json:"query" binding:"required"`
Limit int `json:"limit"`
Start string `json:"start"`
End string `json:"end"`
Direction string `json:"direction"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
return
}
ds, err := loadLogDS(h.ds, req.DatasourceID)
if err != nil || ds.Type != "loki" {
c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"})
return
}
if req.Limit <= 0 {
req.Limit = 200
}
if req.Limit > maxLogQueryLimit {
req.Limit = maxLogQueryLimit
}
if req.Direction == "" {
req.Direction = "backward"
}
end := time.Now()
start := end.Add(-1 * time.Hour)
if req.End != "" {
if t, e := parseLogTime(req.End); e == nil {
end = t
}
}
if req.Start != "" {
if t, e := parseLogTime(req.Start); e == nil {
start = t
}
}
params := url.Values{}
params.Set("query", req.Query)
params.Set("limit", strconv.Itoa(req.Limit))
params.Set("direction", req.Direction)
params.Set("start", fmt.Sprintf("%d", start.UnixNano()))
params.Set("end", fmt.Sprintf("%d", end.UnixNano()))
client := newLogHTTPClient(ds.SkipTLSVerify, time.Duration(60)*time.Second)
target := ds.URL + "/loki/api/v1/query_range?" + params.Encode()
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
applyLogDSAuth(httpReq, ds.BasicAuth, ds.BasicAuthUser, ds.BasicAuthPass)
resp, err := client.Do(httpReq)
if err != nil {
c.JSON(502, model.Response{Code: 502, Message: "查询失败: " + err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
c.JSON(502, model.Response{Code: 502, Message: "Loki 错误: " + string(body)})
return
}
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
entries := flattenLokiStreams(raw)
c.JSON(200, model.Response{Code: 200, Data: gin.H{
"entries": entries, "total": len(entries),
}})
}
// QueryES POST /api/v1/logs/elasticsearch/query
func (h *LogProxyHandler) QueryES(c *gin.Context) {
var req struct {
DatasourceID uint `json:"datasource_id" binding:"required"`
Index string `json:"index"`
Query string `json:"query"`
QueryBody map[string]interface{} `json:"query_body"`
Size int `json:"size"`
Start string `json:"start"`
End string `json:"end"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
return
}
ds, err := loadLogDS(h.ds, req.DatasourceID)
if err != nil || ds.Type != "elasticsearch" {
c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"})
return
}
index := req.Index
if index == "" {
index = ds.DefaultIndex
}
if index == "" {
index = "*"
}
if req.Size <= 0 {
req.Size = 200
}
if req.Size > maxLogQueryLimit {
req.Size = maxLogQueryLimit
}
searchBody := req.QueryBody
if searchBody == nil {
searchBody = map[string]interface{}{
"size": req.Size,
"sort": []interface{}{
map[string]interface{}{"_score": map[string]string{"order": "desc"}},
},
"query": map[string]interface{}{
"bool": map[string]interface{}{
"must": []interface{}{
map[string]interface{}{"query_string": map[string]interface{}{"query": defaultESQuery(req.Query)}},
},
},
},
}
if req.Start != "" || req.End != "" {
// ES 7.2 date_time 仅接受 .SSSZ 格式(strict_date_optional_time 不含 epoch_millis)
// 兼容各种前端时间格式:补全毫秒 // Logan.Li
normalizeTime := func(s string) string {
if s == "" {
return s
}
// 空格 → T(兼容前端未正确转换的格式)// Logan.Li
s = strings.Replace(s, " ", "T", 1)
// 查找时间部分结束位置(Z 或 +)
tzIdx := strings.IndexAny(s, "Z+")
timePart := s
tzPart := ""
if tzIdx >= 0 {
timePart = s[:tzIdx]
tzPart = s[tzIdx:]
}
// 如果已有毫秒部分(.后跟数字),直接返回
if dotIdx := strings.LastIndexByte(timePart, '.'); dotIdx >= 0 {
if dotIdx+1 < len(timePart) && timePart[dotIdx+1] >= '0' && timePart[dotIdx+1] <= '9' {
return s
}
}
return timePart + ".000" + tzPart
}
rangeCond := map[string]interface{}{
"bool": map[string]interface{}{
"should": []interface{}{
map[string]interface{}{"range": map[string]interface{}{"@timestamp": map[string]string{}}},
map[string]interface{}{"range": map[string]interface{}{"timestamp": map[string]string{}}},
},
"minimum_should_match": 1,
},
}
for _, clause := range rangeCond["bool"].(map[string]interface{})["should"].([]interface{}) {
rng := clause.(map[string]interface{})["range"].(map[string]interface{})
for _, v := range rng {
r := v.(map[string]string)
if req.Start != "" {
r["gte"] = normalizeTime(req.Start)
}
if req.End != "" {
r["lte"] = normalizeTime(req.End)
}
}
}
searchBody["query"] = map[string]interface{}{
"bool": map[string]interface{}{
"must": []interface{}{
searchBody["query"],
rangeCond,
},
},
}
}
}
payload, _ := json.Marshal(searchBody)
client := newLogHTTPClient(ds.SkipTLSVerify, 60*time.Second)
target := fmt.Sprintf("%s/%s/_search", ds.URL, strings.Trim(index, "/"))
httpReq, _ := http.NewRequest(http.MethodPost, target, bytes.NewReader(payload))
httpReq.Header.Set("Content-Type", "application/json")
applyLogDSAuth(httpReq, ds.BasicAuth, ds.BasicAuthUser, ds.BasicAuthPass)
resp, err := client.Do(httpReq)
if err != nil {
c.JSON(502, model.Response{Code: 502, Message: "查询失败: " + err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
c.JSON(502, model.Response{Code: 502, Message: "ES 错误: " + string(body)})
return
}
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
entries := flattenESEntries(raw)
c.JSON(200, model.Response{Code: 200, Data: gin.H{
"entries": entries, "total": len(entries),
}})
}
func parseLogTime(s string) (time.Time, error) {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
if n > 1e15 {
return time.Unix(0, n), nil
}
return time.Unix(n, 0), nil
}
// 解析 ISO8601(含 .SSSZ 毫秒后缀,如 2026-06-20T09:33:17.000Z)
return time.Parse(time.RFC3339Nano, s)
}
func defaultESQuery(q string) string {
if strings.TrimSpace(q) == "" {
return "*"
}
return q
}
type logEntryRow struct {
Timestamp string `json:"timestamp"`
Level string `json:"level,omitempty"`
Message string `json:"message"`
Labels map[string]string `json:"labels,omitempty"`
Raw string `json:"raw,omitempty"`
}
func flattenLokiStreams(raw map[string]interface{}) []logEntryRow {
out := []logEntryRow{}
data, _ := raw["data"].(map[string]interface{})
result, _ := data["result"].([]interface{})
for _, item := range result {
stream, _ := item.(map[string]interface{})
labels, _ := stream["stream"].(map[string]interface{})
labelMap := map[string]string{}
for k, v := range labels {
labelMap[k] = fmt.Sprintf("%v", v)
}
values, _ := stream["values"].([]interface{})
for _, val := range values {
pair, _ := val.([]interface{})
if len(pair) < 2 {
continue
}
ts := fmt.Sprintf("%v", pair[0])
msg := fmt.Sprintf("%v", pair[1])
out = append(out, logEntryRow{
Timestamp: ts,
Level: labelMap["level"],
Message: msg,
Labels: labelMap,
Raw: msg,
})
}
}
return out
}
func flattenESEntries(raw map[string]interface{}) []logEntryRow {
out := []logEntryRow{}
hitsWrap, _ := raw["hits"].(map[string]interface{})
hits, _ := hitsWrap["hits"].([]interface{})
for _, h := range hits {
doc, _ := h.(map[string]interface{})
src, _ := doc["_source"].(map[string]interface{})
if src == nil {
continue
}
msg := pickESMessage(src)
level, _ := src["level"].(string)
ts, _ := src["@timestamp"].(string)
if ts == "" {
ts = fmt.Sprintf("%v", src["timestamp"])
}
rawBytes, _ := json.Marshal(src)
out = append(out, logEntryRow{
Timestamp: ts,
Level: level,
Message: msg,
Raw: string(rawBytes),
})
}
return out
}
func pickESMessage(src map[string]interface{}) string {
for _, key := range []string{"message", "msg", "log", "content"} {
if v, ok := src[key]; ok {
return fmt.Sprintf("%v", v)
}
}
b, _ := json.Marshal(src)
if len(b) > 500 {
return string(b[:500]) + "..."
}
return string(b)
}