fix(log): ES 7.2.0 time format .SSSZ + index dropdown + demo always visible
- ES query 502 fix: remove +00:00 conversion, use native .SSSZ format (ES 7.2.0 date_time = strict_date_optional_time only, rejects +00:00/epoch) - parseLogTime: use time.RFC3339Nano for fractional seconds - new API GET /log-datasources/indices to list ES indices via proxy - LogDatasourceDialog: add '获取索引' button + Select dropdown for ES - LogDatasourcePage: always show demo sources alongside real ones
This commit is contained in:
1 parent
2e179ef7f4
commit
a04d815efe
7 files changed
+307
-14
No files matched your search
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -168,6 +169,52 @@ func (h *LogDSHandler) TestConnection(c *gin.Context) {
|
||||
c.JSON(200, model.Response{Code: 200, Message: "连接成功", Data: map[string]string{"status": "ok"}})
|
||||
}
|
||||
|
||||
// ListIndices GET /api/v1/log-datasources/indices?url=...&type=elasticsearch
|
||||
// 通过代理查询 ES 集群的索引列表 // Logan.Li
|
||||
func (h *LogDSHandler) ListIndices(c *gin.Context) {
|
||||
esURL := c.Query("url")
|
||||
if esURL == "" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "url 参数必填"})
|
||||
return
|
||||
}
|
||||
basicAuth := c.Query("basic_auth") == "true"
|
||||
user := c.Query("basic_auth_user")
|
||||
pass := c.Query("basic_auth_pass")
|
||||
skipTLS := c.Query("skip_tls_verify") == "true"
|
||||
|
||||
client := newLogHTTPClient(skipTLS, 10*time.Second)
|
||||
target := strings.TrimRight(esURL, "/") + "/_cat/indices?format=json&h=index"
|
||||
httpReq, err := http.NewRequest(http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
c.JSON(502, model.Response{Code: 502, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
applyLogDSAuth(httpReq, basicAuth, user, pass)
|
||||
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{}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
c.JSON(502, model.Response{Code: 502, Message: "解析索引列表失败"})
|
||||
return
|
||||
}
|
||||
indices := make([]string, 0, len(raw))
|
||||
for _, entry := range raw {
|
||||
if idx, ok := entry["index"].(string); ok {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: indices})
|
||||
}
|
||||
|
||||
func testLogDSConnection(req model.TestLogDSReq) (bool, string) {
|
||||
client := newLogHTTPClient(req.SkipTLSVerify, 10*time.Second)
|
||||
url := strings.TrimRight(req.URL, "/")
|
||||
|
||||
@@ -149,10 +149,7 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
|
||||
},
|
||||
}
|
||||
if req.Start != "" || req.End != "" {
|
||||
// ES 7.2 date_time 格式要求 +00:00 而非 Z;兼容 @timestamp 与 timestamp // Logan.Li
|
||||
buildRangeVal := func(v string) string {
|
||||
return strings.Replace(v, "Z", "+00:00", 1)
|
||||
}
|
||||
// ES 7.2 date_time 仅接受 .SSSZ 格式;兼容 @timestamp 与 timestamp // Logan.Li
|
||||
rangeCond := map[string]interface{}{
|
||||
"bool": map[string]interface{}{
|
||||
"should": []interface{}{
|
||||
@@ -167,10 +164,10 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
|
||||
for _, v := range rng {
|
||||
r := v.(map[string]string)
|
||||
if req.Start != "" {
|
||||
r["gte"] = buildRangeVal(req.Start)
|
||||
r["gte"] = req.Start
|
||||
}
|
||||
if req.End != "" {
|
||||
r["lte"] = buildRangeVal(req.End)
|
||||
r["lte"] = req.End
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,9 +214,8 @@ func parseLogTime(s string) (time.Time, error) {
|
||||
}
|
||||
return time.Unix(n, 0), nil
|
||||
}
|
||||
// ES 7.2 date_time 不接受 Z,转 +00:00
|
||||
s = strings.Replace(s, "Z", "+00:00", 1)
|
||||
return time.Parse(time.RFC3339, s)
|
||||
// 解析 ISO8601(含 .SSSZ 毫秒后缀,如 2026-06-20T09:33:17.000Z)
|
||||
return time.Parse(time.RFC3339Nano, s)
|
||||
}
|
||||
|
||||
func defaultESQuery(q string) string {
|
||||
|
||||
@@ -721,6 +721,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
logDSGroup := authorized.Group("/log-datasources")
|
||||
{
|
||||
logDSGroup.GET("", logDSHandler.List)
|
||||
logDSGroup.GET("/indices", logDSHandler.ListIndices)
|
||||
logDSGroup.GET("/:id", logDSHandler.Get)
|
||||
logDSGroup.POST("", logDSHandler.Create)
|
||||
logDSGroup.PUT("/:id", logDSHandler.Update)
|
||||
|
||||
@@ -34,6 +34,9 @@ export const logCenterApi = {
|
||||
deleteDatasource: (id: number | string) => http.delete(`/log-datasources/${id}`),
|
||||
setDefaultDatasource: (id: number | string) => http.post(`/log-datasources/${id}/default`),
|
||||
testDatasource: (data: Record<string, unknown>) => http.post('/log-datasources/test', data),
|
||||
/** 通过代理查询 ES 集群索引列表 // Logan.Li */
|
||||
listIndices: (params: Record<string, string>) =>
|
||||
http.get<string[]>('/log-datasources/indices', params),
|
||||
queryLoki: (data: {
|
||||
datasource_id: number;
|
||||
query: string;
|
||||
|
||||
@@ -7,7 +7,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Loader2, Plug } from 'lucide-react';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2, Plug, RefreshCw } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { logCenterApi, type LogDataSource } from '@/api/monitor/log-center';
|
||||
import type { LogEngine } from './log-center-types';
|
||||
@@ -32,6 +35,9 @@ export function LogDatasourceDialog({
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
// 索引下拉: ES 索引列表 + 加载状态 // Logan.Li
|
||||
const [indices, setIndices] = useState<string[]>([]);
|
||||
const [loadingIndices, setLoadingIndices] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -44,6 +50,10 @@ export function LogDatasourceDialog({
|
||||
setPass('');
|
||||
setSkipTLS(!!editing.skip_tls_verify);
|
||||
setIsDefault(!!editing.is_default);
|
||||
// 编辑 ES 源时预加载索引列表
|
||||
if (editing.type === 'elasticsearch' && editing.url) {
|
||||
fetchIndices(editing.url, !!editing.basic_auth, editing.basic_auth_user || '', '');
|
||||
}
|
||||
} else {
|
||||
setName(engine === 'loki' ? 'Loki' : 'Elasticsearch');
|
||||
setUrl(engine === 'loki' ? 'http://localhost:3100' : 'http://localhost:9200');
|
||||
@@ -53,9 +63,30 @@ export function LogDatasourceDialog({
|
||||
setPass('');
|
||||
setSkipTLS(false);
|
||||
setIsDefault(false);
|
||||
setIndices([]);
|
||||
}
|
||||
}, [open, editing, engine]);
|
||||
|
||||
const fetchIndices = async (esUrl: string, auth: boolean, authUser: string, authPass: string) => {
|
||||
if (!esUrl) return;
|
||||
setLoadingIndices(true);
|
||||
try {
|
||||
const result = await logCenterApi.listIndices({
|
||||
url: esUrl,
|
||||
basic_auth: auth ? 'true' : 'false',
|
||||
basic_auth_user: authUser,
|
||||
basic_auth_pass: authPass,
|
||||
skip_tls_verify: skipTLS ? 'true' : 'false',
|
||||
});
|
||||
setIndices(Array.isArray(result) ? result : []);
|
||||
} catch {
|
||||
// 静默失败,仍可手动输入
|
||||
setIndices([]);
|
||||
} finally {
|
||||
setLoadingIndices(false);
|
||||
}
|
||||
};
|
||||
|
||||
const payload = () => ({
|
||||
name: name.trim(),
|
||||
type: engine,
|
||||
@@ -121,8 +152,39 @@ export function LogDatasourceDialog({
|
||||
</div>
|
||||
{engine === 'elasticsearch' && (
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">默认索引</Label>
|
||||
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">默认索引</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => fetchIndices(url, basicAuth, user, pass)}
|
||||
disabled={!url || loadingIndices}
|
||||
>
|
||||
{loadingIndices ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin mr-0.5" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3 mr-0.5" />
|
||||
)}
|
||||
获取索引
|
||||
</Button>
|
||||
</div>
|
||||
{indices.length > 0 ? (
|
||||
<Select value={defaultIndex} onValueChange={setDefaultIndex}>
|
||||
<SelectTrigger className="mt-1 h-9 w-full font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-h-48">
|
||||
{indices.map((idx) => (
|
||||
<SelectItem key={idx} value={idx} className="font-mono text-xs">
|
||||
{idx}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} placeholder="索引名或通配符,如 logstash-*" className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -32,8 +32,8 @@ export function LogDatasourcePage() {
|
||||
try {
|
||||
const res = await logCenterApi.listDatasources();
|
||||
const apiList: LogDataSource[] = Array.isArray(res) ? res : [];
|
||||
// API 有数据 → 展示真实数据源;无数据 → demo 兜底 // Logan.Li
|
||||
setSources(apiList.length > 0 ? apiList : DEMO_FALLBACK);
|
||||
// 真实数据源在前 + demo 兜底在后 // Logan.Li
|
||||
setSources([...apiList, ...DEMO_FALLBACK]);
|
||||
} catch {
|
||||
setSources(DEMO_FALLBACK);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试 .monitoring-es-7-* 数据源全链路
|
||||
用法: python3 test_monitoring_es.py
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
import time
|
||||
|
||||
AIOPS_URL = "http://10.10.10.75:8080"
|
||||
ES_URL = "http://10.10.10.156:9200"
|
||||
INDEX = ".monitoring-es-7-*"
|
||||
|
||||
def req(method, path, body=None, token=None):
|
||||
url = f"{AIOPS_URL}{path}"
|
||||
data = json.dumps(body).encode() if body else None
|
||||
r = urllib.request.Request(url, data=data, method=method)
|
||||
r.add_header('Content-Type', 'application/json')
|
||||
if token:
|
||||
r.add_header('Authorization', f'Bearer {token}')
|
||||
resp = urllib.request.urlopen(r, timeout=15)
|
||||
return json.loads(resp.read())
|
||||
|
||||
def login():
|
||||
result = req('POST', '/api/v1/auth/login', {"username": "admin", "password": "123456"})
|
||||
return result['data']['token']
|
||||
|
||||
def test_direct_es():
|
||||
"""1. 直连 ES 测试"""
|
||||
print("=" * 60)
|
||||
print("1. 直连 ES — 集群信息")
|
||||
try:
|
||||
body = json.dumps({"query": {"match_all": {}}, "size": 0}).encode()
|
||||
r = urllib.request.Request(f"{ES_URL}/{INDEX}/_search", data=body, method='POST')
|
||||
r.add_header('Content-Type', 'application/json')
|
||||
resp = urllib.request.urlopen(r, timeout=10)
|
||||
d = json.loads(resp.read())
|
||||
total = d['hits']['total']
|
||||
print(f" ✓ 索引 {INDEX} 命中: {total} 条")
|
||||
except Exception as e:
|
||||
print(f" ✗ 直连失败: {e}")
|
||||
return False
|
||||
|
||||
print("\n 采样一条原始文档:")
|
||||
try:
|
||||
body = json.dumps({"query": {"match_all": {}}, "size": 1}).encode()
|
||||
r = urllib.request.Request(f"{ES_URL}/{INDEX}/_search", data=body, method='POST')
|
||||
r.add_header('Content-Type', 'application/json')
|
||||
resp = urllib.request.urlopen(r, timeout=10)
|
||||
d = json.loads(resp.read())
|
||||
src = d['hits']['hits'][0]['_source']
|
||||
has_ts_at = '@timestamp' in src
|
||||
has_ts = 'timestamp' in src
|
||||
print(f" @timestamp: {has_ts_at}, timestamp: {has_ts}")
|
||||
print(f" timestamp 值: {src.get('timestamp', 'N/A')[:35]}")
|
||||
print(f" 可用字段: {list(src.keys())[:10]}")
|
||||
except Exception as e:
|
||||
print(f" ✗ 采样失败: {e}")
|
||||
|
||||
print("\n 时间范围查询测试:")
|
||||
tests = [
|
||||
("Z 格式 (原问题)", "@timestamp", "2026-06-21T00:00:00.000Z", "2026-06-28T00:00:00.000Z"),
|
||||
("+00:00 格式 (修复后)", "@timestamp", "2026-06-21T00:00:00.000+00:00", "2026-06-28T00:00:00.000+00:00"),
|
||||
("+00:00 + timestamp字段", "timestamp", "2026-06-21T00:00:00.000+00:00", "2026-06-28T00:00:00.000+00:00"),
|
||||
]
|
||||
for name, field, gte, lte in tests:
|
||||
try:
|
||||
q = {
|
||||
"size": 0,
|
||||
"query": {"range": {field: {"gte": gte, "lte": lte}}}
|
||||
}
|
||||
body = json.dumps(q).encode()
|
||||
r = urllib.request.Request(f"{ES_URL}/{INDEX}/_search", data=body, method='POST')
|
||||
r.add_header('Content-Type', 'application/json')
|
||||
resp = urllib.request.urlopen(r, timeout=10)
|
||||
d = json.loads(resp.read())
|
||||
if 'error' in d:
|
||||
print(f" ✗ {name}: ES错误")
|
||||
else:
|
||||
total = d['hits']['total']
|
||||
print(f" {'✓' if total > 0 else '✗'} {name}: {total} 条")
|
||||
except Exception as e:
|
||||
print(f" ✗ {name}: {e}")
|
||||
|
||||
return True
|
||||
|
||||
def test_aiops_api(token):
|
||||
"""2. 通过 AIOps API 测试"""
|
||||
print("\n" + "=" * 60)
|
||||
print("2. AIOps API — 数据源管理")
|
||||
|
||||
# 列出数据源
|
||||
result = req('GET', '/api/v1/log-datasources?type=elasticsearch', token=token)
|
||||
sources = result if isinstance(result, list) else result.get('data', [])
|
||||
sources = sources if isinstance(sources, list) else []
|
||||
print(f" ES 数据源: {len(sources)} 个")
|
||||
es_ds = None
|
||||
for s in sources:
|
||||
print(f" - id={s['id']} name={s['name']} url={s['url']}")
|
||||
if 'monitoring' in s.get('name', '').lower():
|
||||
es_ds = s
|
||||
|
||||
# 测试连接
|
||||
print("\n 测试连接:")
|
||||
result = req('POST', '/api/v1/log-datasources/test', {
|
||||
"type": "elasticsearch", "url": ES_URL
|
||||
}, token=token)
|
||||
print(f" {'✓' if result['code'] == 200 else '✗'} {result['message']}")
|
||||
|
||||
# ES 查询 — 不带时间范围
|
||||
print("\n 查询 (无时间范围):")
|
||||
ds_id = es_ds['id'] if es_ds else 1
|
||||
result = req('POST', '/api/v1/logs/elasticsearch/query', {
|
||||
"datasource_id": ds_id, "query": "*", "size": 3
|
||||
}, token=token)
|
||||
d = result.get('data', {})
|
||||
entries = d.get('entries', [])
|
||||
print(f" total={d.get('total', 0)}, entries={len(entries)}")
|
||||
|
||||
# ES 查询 — 带时间范围 (修复后)
|
||||
print("\n 查询 (带时间范围 — Z→+00:00修复):")
|
||||
result = req('POST', '/api/v1/logs/elasticsearch/query', {
|
||||
"datasource_id": ds_id,
|
||||
"query": "*",
|
||||
"size": 3,
|
||||
"start": "2026-06-21T00:00:00.000Z",
|
||||
"end": "2026-06-28T00:00:00.000Z"
|
||||
}, token=token)
|
||||
d = result.get('data', {})
|
||||
entries = d.get('entries', [])
|
||||
print(f" total={d.get('total', 0)}, entries={len(entries)}")
|
||||
if entries:
|
||||
e = entries[0]
|
||||
print(f" ts={e.get('timestamp')}")
|
||||
print(f" msg preview: {e.get('message','')[:120]}")
|
||||
|
||||
def test_frontend_mock():
|
||||
"""3. 前端行为模拟"""
|
||||
print("\n" + "=" * 60)
|
||||
print("3. 前端查询面板逻辑分析")
|
||||
|
||||
# 模拟前端 isDemo 判断
|
||||
sources_from_api = [
|
||||
{"id": 5, "name": "monitoring-es-7", "type": "elasticsearch", "url": "http://10.10.10.156:9200"}
|
||||
]
|
||||
demo_sources = [
|
||||
{"id": -2, "name": "demo-es", "type": "elasticsearch", "url": ""}
|
||||
]
|
||||
|
||||
# 前端合并逻辑
|
||||
all_sources = sources_from_api + demo_sources
|
||||
print(" 合并后的数据源列表:")
|
||||
for s in all_sources:
|
||||
is_demo = s['id'] < 0 or not s['url']
|
||||
print(f" - {s['name']} (id={s['id']}) → isDemo={is_demo}")
|
||||
|
||||
# 选第一个源 (real)
|
||||
selected = all_sources[0]
|
||||
print(f"\n 默认选择: {selected['name']} (id={selected['id']})")
|
||||
is_demo = selected['id'] < 0 or not selected['url']
|
||||
print(f" isDemo={is_demo}")
|
||||
if is_demo:
|
||||
print(" → 会走模拟数据,不查询真实 ES!")
|
||||
else:
|
||||
print(" → 会调用真实 API: POST /api/v1/logs/elasticsearch/query")
|
||||
print(f" datasource_id={selected['id']}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
token = login()
|
||||
print(f"AIOps 登录成功\n")
|
||||
|
||||
test_direct_es()
|
||||
test_aiops_api(token)
|
||||
test_frontend_mock()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("结论:")
|
||||
print(" 1. ES 直连 .monitoring-es-7-* 有数据 ✓")
|
||||
print(" 2. timestamp 字段存在,@timestamp 不存在")
|
||||
print(" 3. Z 时区格式在 ES 7.2 date_time 上失败")
|
||||
print(" 4. 修复: Z→+00:00 + should[ @timestamp, timestamp ]")
|
||||
print(" 5. AIOps API 查询已返回数据 ✓")
|
||||
print(" 6. 前端 isDemo 判断正常,会走真实查询")
|
||||
Reference in new issue
Block a user