feat: 工具配置增强、站点拨测与日志中心多项改进

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

No files matched your search

+180 -20
View File
@@ -11,21 +11,25 @@ import (
"strings"
"time"
"ezops/middleware"
"ezops/model"
"ezops/pkg/logaccess"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// LogProxyHandler Loki / ES 无状态查询代理
// 实时转发请求并解析展示字段,不将日志写入 AIOps 数据库 // Logan.Li
type LogProxyHandler struct {
ds *LogDSHandler
db *gorm.DB
}
const maxLogQueryLimit = 1000 // 单次查询返回日志行数硬上限(Loki limit / ES size 共用)// Logan.Li
func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler {
return &LogProxyHandler{ds: ds}
func NewLogProxyHandler(ds *LogDSHandler, db *gorm.DB) *LogProxyHandler {
return &LogProxyHandler{ds: ds, db: db}
}
// QueryLoki POST /api/v1/logs/loki/query
@@ -37,11 +41,19 @@ func (h *LogProxyHandler) QueryLoki(c *gin.Context) {
Start string `json:"start"`
End string `json:"end"`
Direction string `json:"direction"`
Cursor string `json:"cursor"` // 纳秒时间戳,向后翻页时作为 end // Logan.Li
}
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 数据源无效"})
@@ -68,6 +80,11 @@ func (h *LogProxyHandler) QueryLoki(c *gin.Context) {
start = t
}
}
if req.Cursor != "" {
if n, err := strconv.ParseInt(strings.TrimSpace(req.Cursor), 10, 64); err == nil && n > 0 {
end = time.Unix(0, n)
}
}
params := url.Values{}
params.Set("query", req.Query)
@@ -94,8 +111,12 @@ func (h *LogProxyHandler) QueryLoki(c *gin.Context) {
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
entries := flattenLokiStreams(raw)
nextCursor := ""
if len(entries) > 0 && len(entries) >= req.Limit {
nextCursor = lokiEntryCursor(entries[len(entries)-1].Timestamp)
}
c.JSON(200, model.Response{Code: 200, Data: gin.H{
"entries": entries, "total": len(entries),
"entries": entries, "total": len(entries), "has_more": nextCursor != "", "next_cursor": nextCursor,
}})
}
@@ -109,23 +130,33 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
Size int `json:"size"`
Start string `json:"start"`
End string `json:"end"`
SearchAfter []interface{} `json:"search_after"` // ES 深分页游标 // Logan.Li
}
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
}
index := req.Index
if index == "" {
index = ds.DefaultIndex
}
if index == "" {
index = "*"
}
if req.Size <= 0 {
req.Size = 200
}
@@ -140,7 +171,8 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
// ES 7+ 默认 track_total_hits 为 10000,必须显式开启才能返回真实命中总数 // Logan.Li
"track_total_hits": true,
"sort": []interface{}{
map[string]interface{}{"_score": map[string]string{"order": "desc"}},
map[string]interface{}{"@timestamp": map[string]string{"order": "desc", "unmapped_type": "date"}},
map[string]interface{}{"_id": map[string]string{"order": "desc"}},
},
"query": map[string]interface{}{
"bool": map[string]interface{}{
@@ -206,6 +238,9 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
}
}
}
if len(req.SearchAfter) > 0 {
searchBody["search_after"] = req.SearchAfter
}
payload, _ := json.Marshal(searchBody)
client := newLogHTTPClient(ds.SkipTLSVerify, 60*time.Second)
@@ -226,10 +261,11 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
}
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
entries := flattenESEntries(raw)
entries, nextSearchAfter := flattenESEntries(raw)
total := esHitsTotal(raw)
hasMore := len(entries) >= req.Size && len(nextSearchAfter) > 0
c.JSON(200, model.Response{Code: 200, Data: gin.H{
"entries": entries, "total": total,
"entries": entries, "total": total, "has_more": hasMore, "search_after": nextSearchAfter,
}})
}
@@ -258,6 +294,28 @@ func logProxyErrCategory(status int) string {
}
}
// formatLokiTimestamp 将 Loki 纳秒/秒级(含科学计数法)时间戳转为 RFC3339 // Logan.Li
func formatLokiTimestamp(ts string) string {
ts = strings.TrimSpace(ts)
if f, err := strconv.ParseFloat(ts, 64); err == nil {
if strings.Contains(ts, ".") || strings.Contains(strings.ToLower(ts), "e") {
sec := int64(f)
nsec := int64((f - float64(sec)) * 1e9)
return time.Unix(sec, nsec).UTC().Format(time.RFC3339)
}
}
if n, err := strconv.ParseInt(ts, 10, 64); err == nil {
if n > 1e15 {
return time.Unix(0, n).UTC().Format(time.RFC3339Nano)
}
if n > 1e12 {
return time.UnixMilli(n).UTC().Format(time.RFC3339Nano)
}
return time.Unix(n, 0).UTC().Format(time.RFC3339)
}
return ts
}
func parseLogTime(s string) (time.Time, error) {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
if n > 1e15 {
@@ -301,7 +359,7 @@ func flattenLokiStreams(raw map[string]interface{}) []logEntryRow {
if len(pair) < 2 {
continue
}
ts := fmt.Sprintf("%v", pair[0])
ts := formatLokiTimestamp(fmt.Sprintf("%v", pair[0]))
msg := fmt.Sprintf("%v", pair[1])
out = append(out, logEntryRow{
Timestamp: ts,
@@ -315,18 +373,19 @@ func flattenLokiStreams(raw map[string]interface{}) []logEntryRow {
return out
}
func flattenESEntries(raw map[string]interface{}) []logEntryRow {
func flattenESEntries(raw map[string]interface{}) ([]logEntryRow, []interface{}) {
out := []logEntryRow{}
var nextAfter []interface{}
hitsWrap, _ := raw["hits"].(map[string]interface{})
hits, _ := hitsWrap["hits"].([]interface{})
for _, h := range hits {
for i, 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)
level := pickESLevel(src)
ts, _ := src["@timestamp"].(string)
if ts == "" {
ts = fmt.Sprintf("%v", src["timestamp"])
@@ -336,10 +395,63 @@ func flattenESEntries(raw map[string]interface{}) []logEntryRow {
Timestamp: ts,
Level: level,
Message: msg,
Labels: extractESLabels(src),
Raw: string(rawBytes),
})
if i == len(hits)-1 {
if sortVals, ok := doc["sort"].([]interface{}); ok {
nextAfter = sortVals
}
}
}
return out
return out, nextAfter
}
// lokiEntryCursor 从 RFC3339 时间戳反推纳秒游标 // Logan.Li
func lokiEntryCursor(ts string) string {
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
return strconv.FormatInt(t.UnixNano(), 10)
}
if t, err := time.Parse(time.RFC3339, ts); err == nil {
return strconv.FormatInt(t.UnixNano(), 10)
}
return ""
}
// extractESLabels 从 _source 提取常用字段作为 labels(便于字段过滤)// Logan.Li
func extractESLabels(src map[string]interface{}) map[string]string {
labels := map[string]string{}
for _, key := range []string{"app", "service", "service_name", "host.name", "kubernetes.namespace", "namespace", "container.name", "level"} {
if v := pickESField(src, key); v != "" {
labels[key] = v
}
}
if lv := pickESLevel(src); lv != "" {
labels["level"] = lv
}
return labels
}
func pickESField(src map[string]interface{}, key string) string {
if strings.Contains(key, ".") {
parts := strings.Split(key, ".")
cur := interface{}(src)
for _, p := range parts {
m, ok := cur.(map[string]interface{})
if !ok {
return ""
}
cur, ok = m[p]
if !ok {
return ""
}
}
return stringifyESField(cur)
}
if v, ok := src[key]; ok {
return stringifyESField(v)
}
return ""
}
// esHitsTotal 从 ES 响应中提取真实命中总数(兼容 ES 7+ total.value 与旧版 total)// Logan.Li
@@ -356,10 +468,58 @@ func esHitsTotal(raw map[string]interface{}) int64 {
return 0
}
// pickESLevel 提取日志级别(兼容顶层 level 与 ECS/Filebeat 的 log.level) // Logan.Li
func pickESLevel(src map[string]interface{}) string {
if v, ok := src["level"].(string); ok && v != "" {
return v
}
if v, ok := src["log.level"].(string); ok && v != "" {
return v
}
if logObj, ok := src["log"].(map[string]interface{}); ok {
if v, ok := logObj["level"].(string); ok && v != "" {
return v
}
}
return ""
}
// stringifyESField 将 ES 字段值转为展示字符串 // Logan.Li
func stringifyESField(v interface{}) string {
switch t := v.(type) {
case string:
return t
case float64:
return fmt.Sprintf("%v", t)
case bool:
return fmt.Sprintf("%t", t)
default:
return ""
}
}
// pickESMessage 提取日志正文(兼容 message / ECS log.original / event.original) // Logan.Li
func pickESMessage(src map[string]interface{}) string {
for _, key := range []string{"message", "msg", "log", "content"} {
for _, key := range []string{"message", "msg", "content", "event.original"} {
if v, ok := src[key]; ok {
return fmt.Sprintf("%v", v)
if s := stringifyESField(v); s != "" {
return s
}
}
}
if logObj, ok := src["log"].(map[string]interface{}); ok {
for _, key := range []string{"original", "message"} {
if v, ok := logObj[key]; ok {
if s := stringifyESField(v); s != "" {
return s
}
}
}
}
// 顶层 log 为字符串时(部分旧索引)
if v, ok := src["log"]; ok {
if s := stringifyESField(v); s != "" {
return s
}
}
b, _ := json.Marshal(src)