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

+480
View File
@@ -0,0 +1,480 @@
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))
}