From 8a11acd0863d2723f6a971732088ca840fced4c0 Mon Sep 17 00:00:00 2001 From: attacker Date: Tue, 7 Jul 2026 20:23:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor(quality):=20Q13=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=B7=A8=E5=9E=8B=E7=BB=84=E4=BB=B6=E6=8B=86=E5=88=86=20?= =?UTF-8?q?=E2=80=94=20UnifiedQueryPanel=20682L=E2=86=92529L=20(-22%),=20?= =?UTF-8?q?=E6=8F=90=E5=8F=96=20constants=20+=20entry-mappers=20+=205=20?= =?UTF-8?q?=E4=B8=AA=20UI=20=E5=AD=90=E7=BB=84=E4=BB=B6=20(QueryControlRow?= =?UTF-8?q?/SecondaryToolbar/LogVolumeChart/ResultsTabs/QueryEmptyStates),?= =?UTF-8?q?=20=E9=9B=B6=E9=80=BB=E8=BE=91=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_EVALUATION.md | 2 +- ROADMAP.md | 38 ++ TDD_PLAN.md | 49 +++ .../monitor/log-center/UnifiedQueryPanel.tsx | 397 ++++++------------ .../unified-query/LogVolumeChart.tsx | 38 ++ .../unified-query/QueryControlRow.tsx | 68 +++ .../unified-query/QueryEmptyStates.tsx | 62 +++ .../log-center/unified-query/ResultsTabs.tsx | 103 +++++ .../unified-query/SecondaryToolbar.tsx | 112 +++++ .../log-center/unified-query/constants.ts | 23 + .../log-center/unified-query/entry-mappers.ts | 44 ++ 11 files changed, 660 insertions(+), 276 deletions(-) create mode 100644 frontend/src/components/monitor/log-center/unified-query/LogVolumeChart.tsx create mode 100644 frontend/src/components/monitor/log-center/unified-query/QueryControlRow.tsx create mode 100644 frontend/src/components/monitor/log-center/unified-query/QueryEmptyStates.tsx create mode 100644 frontend/src/components/monitor/log-center/unified-query/ResultsTabs.tsx create mode 100644 frontend/src/components/monitor/log-center/unified-query/SecondaryToolbar.tsx create mode 100644 frontend/src/components/monitor/log-center/unified-query/constants.ts create mode 100644 frontend/src/components/monitor/log-center/unified-query/entry-mappers.ts diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 4aa7646..94516be 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -129,7 +129,7 @@ | ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done | | 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) | | ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done | -| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6+Q7+Q8+Q9+Q10+Q11+Q12 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口),累计提取 30 个独立组件文件** | +| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6+Q7+Q8+Q9+Q10+Q11+Q12+Q13 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%),累计提取 37 个独立组件文件** | ### 3.2 🟡 中优先级 diff --git a/ROADMAP.md b/ROADMAP.md index 52031e2..22aebff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -484,6 +484,7 @@ services/api.ts (1324L) | Q10 | 前端巨型组件拆分(ApprovalWorkflowPage 705L→167L,提取 types + UserSelectInline + WorkflowEditorModal + WorkflowCard) | P0 | **done** | | Q11 | 前端巨型组件拆分(PipelineRunDetail 678L→273L,提取 types + StageProgressBar + RunsTable + LogConsole) | P0 | **done** | | Q12 | 前端巨型组件拆分(CloudServerImport 699L→404L,提取 StepIndicator + 4 个向导步骤组件 + types,any[]→具体接口) | P0 | **done** | +| Q13 | 前端巨型组件拆分(UnifiedQueryPanel 682L→529L,提取 constants + entry-mappers + 5 个 UI 子组件) | P0 | **done** | --- @@ -848,3 +849,40 @@ pending → in_progress → done / blocked - [x] 17/17 集成回归测试通过(零回归) - [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) - [x] 云服务器导入向导功能不变(4 步骤切换/厂商选择/账号选择/区域选择/发现导入全部正常) + +--- + +## Q13: 前端巨型组件拆分 — UnifiedQueryPanel [done] + +**问题**: `UnifiedQueryPanel.tsx`(682 行)是 Q12 拆分后项目最大的业务组件之一(排除 UI 库 sidebar/data-table),包含日志中心统一查询面板的全部逻辑:常量定义、数据源加载、URL 状态同步、Loki 标签/ES mapping 自动补全、查询执行(Loki/ES 双引擎)、直方图加载、加载更多、Live Tail 实时追踪、标签过滤、字段点击、分享链接、保存查询/告警、图表点击缩时、导出/复制、以及 5 个内联 UI 区块(查询控制行/次级工具栏/Log Volume 面积图/结果标签页/空状态)。单文件过大导致维护困难、代码导航低效。 + +**已完成 (2026-07-07)**: + +将常量、入口映射工具函数、5 个 UI 子组件提取为 `unified-query/` 子目录下的独立文件: + +| 新文件 | 行数 | 职责 | +|--------|------|------| +| `constants.ts` | 23 | DEFAULT_TIME_RANGE / DEFAULT_QUERIES / ENGINE_BADGE 静态常量 | +| `entry-mappers.ts` | 44 | mapRawEntries(LogQueryEntry→LogEntry 映射 + level 解析)+ normalizeISO(ES 7.x 时间格式化)纯函数 | +| `QueryControlRow.tsx` | 68 | 查询控制行(引擎徽标 + QueryAutocompleteInput + 查询按钮 + Live Tail 按钮) | +| `SecondaryToolbar.tsx` | 112 | 次级控件行(TimeRangePicker + 数据源下拉 + ES 索引选择 + 查询上限 + 另存为菜单 + 分享/收藏/历史按钮) | +| `LogVolumeChart.tsx` | 38 | Log Volume 面积图(Recharts AreaChart + 点击缩时 + 引擎主题色渐变) | +| `ResultsTabs.tsx` | 103 | 结果标签页(logs/stats/cluster 三 Tab + logs tab 视图模式切换 + 字段侧栏集成) | +| `QueryEmptyStates.tsx` | 62 | 空状态(未查询引导 / 0 结果提示+重新查询 / loading spinner) | + +主文件 `UnifiedQueryPanel.tsx` 从 **682 行降至 529 行**(-22%),仅保留:50+ 个状态声明、数据源加载 effect、URL 状态双向同步、Loki 标签/ES mapping 自动补全 effect、查询执行核心逻辑(handleRun/handleLoadMore/runLiveTailTick)、事件处理(handleLabelFilter/handleRequestLabelValues/handleFieldClick/handleShareLink/handleChartClick/handleExportLogs/handleCopyLogs)、派生数据(fields/commonLabels/sortedEntries/pagedEntries)、以及 7 个子组件编排。 + +**提取原则**: constants 和 entry-mappers 为纯数据/函数(零 React 依赖);QueryControlRow/SecondaryToolbar 为受控组件(接收状态 + 回调 props,内部仅 useTranslation);LogVolumeChart 为纯展示组件(接收 histogramData + color + onChartClick);ResultsTabs 为受控容器(接收 20+ props 管理 3 个 Tab 切换);QueryEmptyStates 为三态纯展示组件。零逻辑变更。 + +**验收标准**: +- [x] UnifiedQueryPanel.tsx 从 682 行降至 **529 行**(-22%) +- [x] 7 个提取的文件全部 < 115 行(最大 SecondaryToolbar 112 行) +- [x] `tsc -b` 零错误 +- [x] `npm run build` 零错误(✓ built in 17.57s) +- [x] ESLint errors 保持 **0**(UnifiedQueryPanel + unified-query/ 目录零 errors/warnings) +- [x] ESLint warnings 保持 **64**(无新增) +- [x] 全局 ESLint any 保持 **0** +- [x] `go build` 零错误 +- [x] 17/17 集成回归测试通过(零回归) +- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) +- [x] 日志中心查询面板功能不变(Loki/ES 双引擎查询/直方图/标签过滤/Live Tail/分享链接/保存查询/告警创建全部正常) diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 8ff02a4..1d9db23 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -1280,3 +1280,52 @@ python3 /srv/aiops/tests/integration_test.py --host 127.0.0.1 --port 8080 | tail - [x] 17/17 集成回归测试通过(零回归) - [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) - [x] 云服务器导入向导功能不变(4 步骤切换 / 厂商选择 / 账号选择 / 区域选择搜索+全选 / 主机发现+导入) + +--- + +## P0-Q13: 前端巨型组件拆分 — UnifiedQueryPanel + +### 验证用例 + +```bash +# U1: 主文件行数 +wc -l /srv/aiops/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx +# 期望: < 600 行 + +# U2: 提取文件存在且行数合理 +wc -l /srv/aiops/frontend/src/components/monitor/log-center/unified-query/*.ts /srv/aiops/frontend/src/components/monitor/log-center/unified-query/*.tsx +# 期望: 7 个文件,每个 < 120 行 + +# U3: TypeScript 编译 +cd /srv/aiops/frontend && npx tsc -b +# 期望: 零错误 + +# U4: Vite 构建 +npm run build 2>&1 | tail -3 +# 期望: built in Ns + +# U5: ESLint +npx eslint src/components/monitor/log-center/UnifiedQueryPanel.tsx src/components/monitor/log-center/unified-query/ --format compact +# 期望: 0 errors, 0 warnings + +# U6: go build +cd /srv/aiops/backend && go build ./... +# 期望: 零错误 + +# U7: 集成回归 +python3 /srv/aiops/tests/integration_test.py --host 127.0.0.1 --port 8080 | tail -3 +# 期望: 17/17 passed +``` + +### 通过标准 +- [x] UnifiedQueryPanel.tsx 从 682 行降至 **529 行**(< 600 ✅,-22%) +- [x] 7 个提取文件全部 < 115 行(constants 23L + entry-mappers 44L + LogVolumeChart 38L + QueryControlRow 68L + QueryEmptyStates 62L + ResultsTabs 103L + SecondaryToolbar 112L) +- [x] `tsc -b` 零错误 +- [x] `npm run build` 零错误(✓ built in 17.57s) +- [x] ESLint errors 保持 **0**(UnifiedQueryPanel + unified-query/ 目录零 warnings) +- [x] ESLint warnings 保持 **64**(无新增) +- [x] 全局 ESLint any 保持 **0** +- [x] `go build` 零错误 +- [x] 17/17 集成回归测试通过(零回归) +- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) +- [x] 日志中心查询面板功能不变(Loki/ES 双引擎查询/直方图/标签过滤/Live Tail/分享链接/保存查询/告警创建全部正常) diff --git a/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx b/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx index 46c26d2..76c3c36 100644 --- a/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx +++ b/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx @@ -1,53 +1,34 @@ -// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)// Logan.Li +// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API) +// 主文件:状态管理 + 查询逻辑 + effects + 子组件编排 +// UI 子组件提取至 ./unified-query/ 子目录 import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useSearchParams, useLocation } from 'react-router-dom'; -import { Play, Loader2, Clock, Save, ChevronDown, Bell, Link2, Bookmark, Radio } from 'lucide-react'; -import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; -import { TimeRangePicker } from './TimeRangePicker'; -import { LogFieldSidebar } from './LogFieldSidebar'; -import { EnhancedLogResults, extractCommonLabels } from './EnhancedLogResults'; import { LogQueryToolbar } from './LogQueryToolbar'; -import { StatChartPanel } from './StatChartPanel'; -import { LogClusterPanel } from './LogClusterPanel'; import { SaveQueryDialog, SaveAlertDialog } from './SaveQueryDialog'; import { QueryHistoryPanel } from './QueryHistoryPanel'; import { SavedQueriesPanel } from './SavedQueriesPanel'; import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog'; -import { QueryAutocompleteInput } from './QueryAutocompleteInput'; import { QuickQueryBar } from './QuickQueryBar'; import { parseLogQueryUrl, buildLogQueryParams, buildShareUrl } from './query-url-state'; -import { logCenterApi, type LogDataSource, type LogQueryEntry } from '@/api'; +import { logCenterApi, type LogDataSource } from '@/api'; import { copyToClipboard } from '@/utils/clipboard'; -import { buildLogCopyText, formatHistogramCount, formatHistogramTick, formatLogTimestamp } from './log-format'; -import { EsIndexPicker } from './EsIndexPicker'; +import { buildLogCopyText, formatLogTimestamp } from './log-format'; import { LokiLabelBrowser } from './LokiLabelBrowser'; import { applyESFieldFilter, applyLokiLabelFilter, extractLokiSelector } from './query-builder'; import type { LogEngine, TimeRange, ResultTab, ViewMode, LogEntry, LogField, StatTable, LogCluster, QueryHistoryItem, SavedQuery } from './types-v3'; import { ENGINE_META, buildStatTableFromEntries, buildClustersFromEntries, extractFields, generateHistogramData, historyStorage } from './types-v3'; +import { extractCommonLabels } from './EnhancedLogResults'; import { useToast } from '@/hooks/use-toast'; import { useTranslation } from 'react-i18next'; import { getErrorMessage } from '@/lib/utils'; - -// 默认最近5分钟 // Logan.Li -const DEFAULT_TIME_RANGE: TimeRange = { - start: new Date(Date.now() - 5 * 60000).toISOString().replace('T', ' ').substring(0, 19), - end: new Date().toISOString().replace('T', ' ').substring(0, 19), - label: 'Last 5 min', - type: 'relative', -}; - -// 引擎默认查询语句 // Logan.Li -const DEFAULT_QUERIES: Record<'loki' | 'elasticsearch', string> = { - // 默认查询语句:Loki 用 LogQL / ES 用 Lucene,对齐平台 admin-api 服务 // Logan.Li - loki: '{app="admin-api"} |= "INFO"', - elasticsearch: 'app:admin-api', -}; - -// 引擎徽标:左侧小圆点 + 语言标签 // Logan.Li -const ENGINE_BADGE: Record<'loki' | 'elasticsearch', { label: string; dotColor: string }> = { - loki: { label: 'LogQL', dotColor: '#06b6d4' }, - elasticsearch: { label: 'Lucene', dotColor: '#22c55e' }, -}; +// 提取的子组件 + 常量 + 工具函数 +import { DEFAULT_TIME_RANGE, DEFAULT_QUERIES, ENGINE_BADGE } from './unified-query/constants'; +import { mapRawEntries, normalizeISO } from './unified-query/entry-mappers'; +import { QueryControlRow } from './unified-query/QueryControlRow'; +import { SecondaryToolbar } from './unified-query/SecondaryToolbar'; +import { LogVolumeChart } from './unified-query/LogVolumeChart'; +import { ResultsTabs } from './unified-query/ResultsTabs'; +import { QueryEmptyStates } from './unified-query/QueryEmptyStates'; interface Props { engine: LogEngine; onRuleCreated: () => void; } @@ -62,7 +43,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { const meta = ENGINE_META[effEngine]; const badge = ENGINE_BADGE[effEngine]; - // 从后端加载真实数据源(无 demo 兜底,无源时引导用户前往日志库配置)// Logan.Li + // 从后端加载真实数据源(无 demo 兜底,无源时引导用户前往日志库配置) const [sources, setSources] = useState([]); useEffect(() => { logCenterApi.listDatasources(effEngine).then((res) => { @@ -73,9 +54,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { const [query, setQuery] = useState(DEFAULT_QUERIES[effEngine]); const [dsId, setDsId] = useState(String(sources[0]?.id || '')); - // ES 查询允许在面板内临时覆盖索引(默认取自数据源配置)// Logan.Li const [esIndex, setEsIndex] = useState(''); - // API 单次查询返回条数上限(后端限制 1000),Loki 用 limit / ES 用 size // Logan.Li const [queryLimit, setQueryLimit] = useState(1000); const [timeRange, setTimeRange] = useState(DEFAULT_TIME_RANGE); const [loading, setLoading] = useState(false); @@ -116,7 +95,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { const currentSource = sources.find((s) => String(s.id) === dsId); - // 数据源加载后选中默认或首个;首次从 URL 恢复查询状态 // Logan.Li + // 数据源加载后选中默认或首个;首次从 URL 恢复查询状态 useEffect(() => { if (sources.length === 0) return; if (!urlBootstrapped.current) { @@ -134,19 +113,16 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { if (!dsId && def) setDsId(String(def.id)); }, [sources, searchParams, dsId]); - // 查询状态同步到 URL(可分享)// Logan.Li + // 查询状态同步到 URL(可分享) useEffect(() => { if (!urlBootstrapped.current) return; const p = buildLogQueryParams({ - query, - datasourceId: dsId || undefined, - index: esIndex || undefined, - timeRange, + query, datasourceId: dsId || undefined, index: esIndex || undefined, timeRange, }); setSearchParams(p, { replace: true }); }, [query, dsId, esIndex, timeRange, setSearchParams]); - // Loki 标签名(自动补全)// Logan.Li + // Loki 标签名(自动补全) useEffect(() => { if (effEngine !== 'loki' || !currentSource?.id) return; logCenterApi.listLokiLabels(currentSource.id, timeIsoParams()) @@ -154,7 +130,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { .catch(() => setLabelNames([])); }, [effEngine, currentSource?.id, timeIsoParams]); - // ES mapping 索引字段(Kibana 风格侧栏)// Logan.Li + // ES mapping 索引字段(Kibana 风格侧栏) useEffect(() => { if (effEngine !== 'elasticsearch' || !currentSource?.id) return; logCenterApi.esMapping({ datasource_id: currentSource.id, index: esIndex || undefined }) @@ -170,65 +146,35 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { const fields = useMemo(() => extractFields(entries), [entries]); const commonLabels = useMemo(() => extractCommonLabels(entries), [entries]); - const sortedEntries = useMemo(() => { const a = [...entries]; return sortDirection === 'asc' ? a.sort((x, y) => x.timestamp.localeCompare(y.timestamp)) : a.sort((x, y) => y.timestamp.localeCompare(x.timestamp)); }, [entries, sortDirection]); + const sortedEntries = useMemo(() => { + const a = [...entries]; + return sortDirection === 'asc' ? a.sort((x, y) => x.timestamp.localeCompare(y.timestamp)) : a.sort((x, y) => y.timestamp.localeCompare(x.timestamp)); + }, [entries, sortDirection]); const totalPages = Math.max(1, Math.ceil(sortedEntries.length / perPage)); const pagedEntries = useMemo(() => { const s = (page - 1) * perPage; return sortedEntries.slice(s, s + perPage); }, [sortedEntries, page, perPage]); - const mapRawEntries = useCallback((rawEntries: LogQueryEntry[]): LogEntry[] => rawEntries.map((e) => { - const raw = String(e.raw || e.message || ''); - let level = String(e.level || e.labels?.detected_level || e.labels?.level || ''); - if (!level && effEngine === 'elasticsearch' && raw) { - try { - const parsed = JSON.parse(raw) as Record; - level = String(parsed.level || parsed['log.level'] || (parsed.log as Record)?.level || ''); - } catch { /* 非 JSON */ } - } - if (!level) { - const levelMatch = raw.match(/\s(INFO|WARN|WARNING|ERROR|DEBUG|TRACE|FATAL)\s/i); - if (levelMatch) level = levelMatch[1].toLowerCase(); - } - const labels = e.labels || {}; - return { - timestamp: formatLogTimestamp(e.timestamp || ''), - level, - message: String(e.message || raw), - labels, - raw, - source: labels.app || labels.service_name || '', - }; - }), [effEngine]); - const fetchHistogram = useCallback(async (dsIdNum: number, startISO: string, endISO: string) => { try { if (effEngine === 'loki') { const res = await logCenterApi.lokiHistogram({ datasource_id: dsIdNum, query, start: startISO, end: endISO }); const buckets = res?.buckets || []; if (buckets.length > 0) { - setHistogramData(buckets.map((b) => ({ - timestamp: formatLogTimestamp(b.timestamp), - count: b.count, - }))); + setHistogramData(buckets.map((b) => ({ timestamp: formatLogTimestamp(b.timestamp), count: b.count }))); return; } } else { - const res = await logCenterApi.esHistogram({ - datasource_id: dsIdNum, query, index: esIndex || undefined, start: startISO, end: endISO, - }); + const res = await logCenterApi.esHistogram({ datasource_id: dsIdNum, query, index: esIndex || undefined, start: startISO, end: endISO }); const buckets = res?.buckets || []; if (buckets.length > 0) { - setHistogramData(buckets.map((b) => ({ - timestamp: formatLogTimestamp(b.timestamp), - count: b.count, - }))); + setHistogramData(buckets.map((b) => ({ timestamp: formatLogTimestamp(b.timestamp), count: b.count }))); return; } } } catch { /* 直方图失败时回退客户端聚合 */ } }, [effEngine, query, esIndex]); - // 执行查询 — 接入真实 API(无 mock)// Logan.Li + // 执行查询 — 接入真实 API(无 mock) const handleRun = useCallback(async () => { - // 空查询兜底:后端 Query 字段 required,空值会 400,这里提前拦截 // Logan.Li if (!currentSource || !query.trim()) return; const dsIdNum = currentSource.id; setLoading(true); @@ -238,8 +184,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { const t0 = performance.now(); try { - const startISO = (timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z')).replace(' ', 'T'); - const endISO = (timeRange.end + (timeRange.end.includes('T') ? '' : '.000Z')).replace(' ', 'T'); + const startISO = normalizeISO(timeRange.start); + const endISO = normalizeISO(timeRange.end); const data = effEngine === 'loki' ? await logCenterApi.queryLoki({ @@ -250,7 +196,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { }); const rawEntries = data?.entries || []; - const mapped = mapRawEntries(rawEntries); + const mapped = mapRawEntries(rawEntries, effEngine); setEntries(mapped); void fetchHistogram(dsIdNum, startISO, endISO); @@ -277,14 +223,14 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [effEngine, query, currentSource, timeRange, toast, queryLimit, esIndex, mapRawEntries, fetchHistogram]); + }, [effEngine, query, currentSource, timeRange, toast, queryLimit, esIndex, fetchHistogram]); const handleLoadMore = useCallback(async () => { if (!currentSource || !hasMore || loadingMore) return; setLoadingMore(true); try { - const startISO = (timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z')).replace(' ', 'T'); - const endISO = (timeRange.end + (timeRange.end.includes('T') ? '' : '.000Z')).replace(' ', 'T'); + const startISO = normalizeISO(timeRange.start); + const endISO = normalizeISO(timeRange.end); const dsIdNum = currentSource.id; const data = effEngine === 'loki' ? await logCenterApi.queryLoki({ @@ -295,7 +241,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { datasource_id: dsIdNum, index: esIndex || undefined, query, size: queryLimit, start: startISO, end: endISO, search_after: searchAfter, }); - const more = mapRawEntries(data?.entries || []); + const more = mapRawEntries(data?.entries || [], effEngine); setEntries((prev) => { const merged = [...prev, ...more]; setStatTable(buildStatTableFromEntries(merged)); @@ -311,7 +257,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { setLoadingMore(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentSource, hasMore, loadingMore, effEngine, query, queryLimit, timeRange, esIndex, nextCursor, searchAfter, mapRawEntries, toast]); + }, [currentSource, hasMore, loadingMore, effEngine, query, queryLimit, timeRange, esIndex, nextCursor, searchAfter, toast]); const handleLabelFilter = useCallback((key: string, value: string, op: 'include' | 'exclude') => { if (effEngine === 'loki') { @@ -375,7 +321,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { : await logCenterApi.queryElasticsearch({ datasource_id: currentSource.id, index: esIndex || undefined, query, size: 200, start: startISO, end: endISO, }); - const mapped = mapRawEntries(data?.entries || []); + const mapped = mapRawEntries(data?.entries || [], effEngine); setEntries((prev) => { const keys = new Set(prev.map((e) => `${e.timestamp}|${e.message}`)); const fresh = mapped.filter((e) => !keys.has(`${e.timestamp}|${e.message}`)); @@ -388,7 +334,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { return merged; }); } catch { /* Live tail 静默失败 */ } - }, [currentSource, query, effEngine, esIndex, mapRawEntries]); + }, [currentSource, query, effEngine, esIndex]); useEffect(() => { if (!liveTail) return undefined; @@ -416,11 +362,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { const toggleField = useCallback((n: string) => setSelectedFields(p => p.includes(n) ? p.filter(f => f !== n) : [...p, n]), []); const handleSaveMenu = (a: 'query' | 'alert') => { setSaveMenuOpen(false); - if (a === 'query') { - setSaveQueryOpen(true); - } else { - setSaveAlertOpen(true); - } + if (a === 'query') setSaveQueryOpen(true); + else setSaveAlertOpen(true); }; const handleChartClick = useCallback((st: { activeLabel?: string }) => { @@ -460,99 +403,45 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { return (
- {/* ===== 查询控制行 ===== */} -
-
-
- - {badge.label} -
- - - -
-
- {/* 次级控件行(时间 / 数据源 / 索引 / 上限 / 另存为 / 历史)// Logan.Li */} -
- + { setLiveTail(false); void handleRun(); }} + onToggleLiveTail={() => setLiveTail((v) => !v)} + /> - + { void handleShareLink(); }} + onOpenSaved={() => setSavedPanelOpen(true)} + onOpenHistory={() => setHistoryPanelOpen(true)} + /> - {effEngine === 'elasticsearch' && ( - - )} - -
- {t('monitor.logPanel.limit')} - { - const v = parseInt(e.target.value, 10); - setQueryLimit(Number.isNaN(v) ? 1000 : Math.min(1000, Math.max(1, v))); - }} - className="w-14 h-6 text-xs text-center rounded outline-none" - style={{ background: 'var(--dg-bg-secondary)', color: 'var(--dg-text-primary)', border: '1px solid var(--dg-border)' }} - /> -
- -
- - {saveMenuOpen && (<> -
setSaveMenuOpen(false)} /> -
- - -
- )} -
- - - - -
- - {/* 无数据源时引导用户前往日志库配置 // Logan.Li */} + {/* 无数据源时引导用户前往日志库配置 */} {sources.length === 0 && (
{t('monitor.logPanel.noDatasourceHint', { name: meta.name })} @@ -573,103 +462,61 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { {/* 统计条 + 工具栏 */} {entries.length > 0 && !loading && ( - { setPerPage(n); setPage(1); }} page={page} totalPages={totalPages} onPageChange={setPage} sortDirection={sortDirection} onSortToggle={() => setSortDirection(d => d === 'desc' ? 'asc' : 'desc')} onExport={handleExportLogs} onCopy={() => { void handleCopyLogs(); }} onBellClick={() => setAlertDialogOpen(true)} hasMore={hasMore} loadingMore={loadingMore} onLoadMore={() => { void handleLoadMore(); }} /> + { setPerPage(n); setPage(1); }} + page={page} totalPages={totalPages} onPageChange={setPage} + sortDirection={sortDirection} onSortToggle={() => setSortDirection(d => d === 'desc' ? 'asc' : 'desc')} + onExport={handleExportLogs} onCopy={() => { void handleCopyLogs(); }} + onBellClick={() => setAlertDialogOpen(true)} + hasMore={hasMore} loadingMore={loadingMore} onLoadMore={() => { void handleLoadMore(); }} + /> )} {/* Log Volume 面积图 */} {entries.length > 0 && ( -
- - - - - - - - - - - - [`${v} ${t('monitor.logPanel.chartCountSuffix')}`, t('monitor.logPanel.chartCountLabel')]} labelFormatter={(l: string) => `${t('monitor.logPanel.chartTimePrefix')} ${l}`} /> - - - -
+ )} {/* 结果标签页 */} {entries.length > 0 && ( -
-
- {(['logs', 'stats', 'cluster'] as ResultTab[]).map(tab => { - const labels: Record = { logs: t('monitor.logPanel.tabLogs'), stats: t('monitor.logPanel.tabStats'), cluster: t('monitor.logPanel.tabCluster') }; - return ( - - ); - })} -
- -
- {resultTab === 'logs' && (<> -
- -
- -
-
-
-
- {(['raw', 'field', 'table'] as const).map(m => ( - - ))} -
- - -
- )} - {resultTab === 'stats' && setSaveAlertOpen(true)} onSaveAsQuery={() => setSaveQueryOpen(true)} />} - {resultTab === 'cluster' && } -
-
+ setSaveAlertOpen(true)} + onSaveAsQuery={() => setSaveQueryOpen(true)} + /> )} - {/* 空状态:从未查询 vs 查询返回 0 结果 */} - {!loading && entries.length === 0 && !hasQueried && ( -
- {t('monitor.logPanel.emptyHint')} - {meta.queryPlaceholder} -
- )} - {!loading && entries.length === 0 && hasQueried && ( -
-
- - - -
-
-

{t('monitor.logPanel.noMatch')}

-

- {t('monitor.logPanel.noMatchDesc', { ms: queryTime })} -

-
- -
- )} - - {/* Loading */} - {loading && entries.length === 0 && ( -
- -
- )} + {/* 空状态 */} + {/* 对话框 */} setSaveQueryOpen(false)} engine={effEngine as LogEngine} query={query} timeRange={timeRange} datasourceId={currentSource?.id} datasourceName={currentSource?.name} /> diff --git a/frontend/src/components/monitor/log-center/unified-query/LogVolumeChart.tsx b/frontend/src/components/monitor/log-center/unified-query/LogVolumeChart.tsx new file mode 100644 index 0000000..bf8d425 --- /dev/null +++ b/frontend/src/components/monitor/log-center/unified-query/LogVolumeChart.tsx @@ -0,0 +1,38 @@ +// unified-query/LogVolumeChart.tsx — Log Volume 面积图(直方图 + 点击缩时) +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { formatHistogramCount, formatHistogramTick } from '../log-format'; +import { useTranslation } from 'react-i18next'; + +interface LogVolumeChartProps { + effEngine: 'loki' | 'elasticsearch'; + histogramData: { timestamp: string; count: number }[]; + color: string; + onChartClick: (st: { activeLabel?: string }) => void; +} + +export function LogVolumeChart({ effEngine, histogramData, color, onChartClick }: LogVolumeChartProps) { + const { t } = useTranslation(); + return ( +
+ + + + + + + + + + + + [`${v} ${t('monitor.logPanel.chartCountSuffix')}`, t('monitor.logPanel.chartCountLabel')]} + labelFormatter={(l: string) => `${t('monitor.logPanel.chartTimePrefix')} ${l}`} + /> + + + +
+ ); +} diff --git a/frontend/src/components/monitor/log-center/unified-query/QueryControlRow.tsx b/frontend/src/components/monitor/log-center/unified-query/QueryControlRow.tsx new file mode 100644 index 0000000..2c18f44 --- /dev/null +++ b/frontend/src/components/monitor/log-center/unified-query/QueryControlRow.tsx @@ -0,0 +1,68 @@ +// unified-query/QueryControlRow.tsx — 查询控制行(引擎徽标 + 输入框 + 查询按钮 + Live Tail) +import { Play, Loader2, Radio } from 'lucide-react'; +import { QueryAutocompleteInput } from '../QueryAutocompleteInput'; +import { useTranslation } from 'react-i18next'; + +interface QueryControlRowProps { + badge: { label: string; dotColor: string }; + query: string; + onQueryChange: (q: string) => void; + effEngine: 'loki' | 'elasticsearch'; + metaQueryPlaceholder: string; + labelNames: string[]; + labelValues: string[]; + mappingFields: string[]; + onRequestLabelValues: (label: string) => void; + loading: boolean; + liveTail: boolean; + hasSource: boolean; + onRun: () => void; + onToggleLiveTail: () => void; +} + +export function QueryControlRow({ + badge, query, onQueryChange, effEngine, metaQueryPlaceholder, + labelNames, labelValues, mappingFields, onRequestLabelValues, + loading, liveTail, hasSource, onRun, onToggleLiveTail, +}: QueryControlRowProps) { + const { t } = useTranslation(); + const canQuery = hasSource && query.trim().length > 0; + return ( +
+
+
+ + {badge.label} +
+ + + +
+
+ ); +} diff --git a/frontend/src/components/monitor/log-center/unified-query/QueryEmptyStates.tsx b/frontend/src/components/monitor/log-center/unified-query/QueryEmptyStates.tsx new file mode 100644 index 0000000..843a8dd --- /dev/null +++ b/frontend/src/components/monitor/log-center/unified-query/QueryEmptyStates.tsx @@ -0,0 +1,62 @@ +// unified-query/QueryEmptyStates.tsx — 查询空状态(未查询 / 0 结果 / loading) +import { Play, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface QueryEmptyStatesProps { + loading: boolean; + entriesCount: number; + hasQueried: boolean; + queryTime: number; + metaQueryPlaceholder: string; + onRequery: () => void; +} + +export function QueryEmptyStates(props: QueryEmptyStatesProps) { + const { loading, entriesCount, hasQueried, queryTime, metaQueryPlaceholder, onRequery } = props; + const { t } = useTranslation(); + + // 未查询 + if (!loading && entriesCount === 0 && !hasQueried) { + return ( +
+ {t('monitor.logPanel.emptyHint')} + {metaQueryPlaceholder} +
+ ); + } + + // 查询返回 0 结果 + if (!loading && entriesCount === 0 && hasQueried) { + return ( +
+
+ + + +
+
+

{t('monitor.logPanel.noMatch')}

+

+ {t('monitor.logPanel.noMatchDesc', { ms: queryTime })} +

+
+ +
+ ); + } + + // Loading + if (loading && entriesCount === 0) { + return ( +
+ +
+ ); + } + + return null; +} diff --git a/frontend/src/components/monitor/log-center/unified-query/ResultsTabs.tsx b/frontend/src/components/monitor/log-center/unified-query/ResultsTabs.tsx new file mode 100644 index 0000000..8058426 --- /dev/null +++ b/frontend/src/components/monitor/log-center/unified-query/ResultsTabs.tsx @@ -0,0 +1,103 @@ +// unified-query/ResultsTabs.tsx — 结果标签页(logs/stats/cluster)+ logs tab 视图控制 +import { LogFieldSidebar } from '../LogFieldSidebar'; +import { EnhancedLogResults } from '../EnhancedLogResults'; +import { StatChartPanel } from '../StatChartPanel'; +import { LogClusterPanel } from '../LogClusterPanel'; +import { useTranslation } from 'react-i18next'; +import type { LogEngine, ResultTab, ViewMode, LogEntry, LogField, StatTable, LogCluster } from '../types-v3'; + +interface ResultsTabsProps { + resultTab: ResultTab; + setResultTab: (tab: ResultTab) => void; + effEngine: 'loki' | 'elasticsearch'; + metaColor: string; + // logs tab + fields: LogField[]; + indexedFields: LogField[]; + selectedFields: string[]; + onFieldClick: (field: string) => void; + onToggleDisplayField: (n: string) => void; + loading: boolean; + pagedEntries: LogEntry[]; + viewMode: ViewMode; + setViewMode: (m: ViewMode) => void; + wrapLines: boolean; + setWrapLines: (v: boolean) => void; + truncate: boolean; + setTruncate: (v: boolean) => void; + commonLabels: ReturnType; + onLabelFilter: (key: string, value: string, op: 'include' | 'exclude') => void; + // stats / cluster tab + statTable?: StatTable; + clusters: LogCluster[]; + query: string; + onSaveAsAlert: () => void; + onSaveAsQuery: () => void; +} + +export function ResultsTabs(props: ResultsTabsProps) { + const { t } = useTranslation(); + const { + resultTab, setResultTab, metaColor, + fields, indexedFields, selectedFields, onFieldClick, onToggleDisplayField, loading, + pagedEntries, viewMode, setViewMode, wrapLines, setWrapLines, truncate, setTruncate, + commonLabels, onLabelFilter, + statTable, clusters, query, onSaveAsAlert, onSaveAsQuery, + } = props; + const effEngine = props.effEngine as LogEngine; + + const labels: Record = { + logs: t('monitor.logPanel.tabLogs'), + stats: t('monitor.logPanel.tabStats'), + cluster: t('monitor.logPanel.tabCluster'), + }; + + return ( +
+
+ {(['logs', 'stats', 'cluster'] as ResultTab[]).map(tab => ( + + ))} +
+ +
+ {resultTab === 'logs' && (<> +
+ +
+ +
+
+
+
+ {(['raw', 'field', 'table'] as const).map(m => ( + + ))} +
+ + +
+ )} + {resultTab === 'stats' && } + {resultTab === 'cluster' && } +
+
+ ); +} diff --git a/frontend/src/components/monitor/log-center/unified-query/SecondaryToolbar.tsx b/frontend/src/components/monitor/log-center/unified-query/SecondaryToolbar.tsx new file mode 100644 index 0000000..1a65dd3 --- /dev/null +++ b/frontend/src/components/monitor/log-center/unified-query/SecondaryToolbar.tsx @@ -0,0 +1,112 @@ +// unified-query/SecondaryToolbar.tsx — 次级控件行(时间/数据源/索引/上限/另存为/分享/收藏/历史) +import { Save, ChevronDown, Bell, Link2, Bookmark, Clock } from 'lucide-react'; +import { TimeRangePicker } from '../TimeRangePicker'; +import { EsIndexPicker } from '../EsIndexPicker'; +import { useTranslation } from 'react-i18next'; +import type { LogDataSource } from '@/api'; +import type { TimeRange } from '../types-v3'; + +interface SecondaryToolbarProps { + timeRange: TimeRange; + onTimeRangeChange: (tr: TimeRange) => void; + dsId: string; + onDsIdChange: (id: string) => void; + sources: LogDataSource[]; + engineName: string; + effEngine: 'loki' | 'elasticsearch'; + currentSourceId: number | undefined; + esIndex: string; + onEsIndexChange: (idx: string) => void; + queryLimit: number; + onQueryLimitChange: (n: number) => void; + saveMenuOpen: boolean; + setSaveMenuOpen: (open: boolean) => void; + onSaveMenuSelect: (action: 'query' | 'alert') => void; + onShareLink: () => void; + onOpenSaved: () => void; + onOpenHistory: () => void; +} + +export function SecondaryToolbar(props: SecondaryToolbarProps) { + const { + timeRange, onTimeRangeChange, dsId, onDsIdChange, sources, engineName, + effEngine, currentSourceId, esIndex, onEsIndexChange, + queryLimit, onQueryLimitChange, + saveMenuOpen, setSaveMenuOpen, onSaveMenuSelect, + onShareLink, onOpenSaved, onOpenHistory, + } = props; + const { t } = useTranslation(); + + return ( +
+ + + + + {effEngine === 'elasticsearch' && ( + + )} + +
+ {t('monitor.logPanel.limit')} + { + const v = parseInt(e.target.value, 10); + onQueryLimitChange(Number.isNaN(v) ? 1000 : Math.min(1000, Math.max(1, v))); + }} + className="w-14 h-6 text-xs text-center rounded outline-none" + style={{ background: 'var(--dg-bg-secondary)', color: 'var(--dg-text-primary)', border: '1px solid var(--dg-border)' }} + /> +
+ +
+ + {saveMenuOpen && (<> +
setSaveMenuOpen(false)} /> +
+ + +
+ )} +
+ + + + +
+ ); +} diff --git a/frontend/src/components/monitor/log-center/unified-query/constants.ts b/frontend/src/components/monitor/log-center/unified-query/constants.ts new file mode 100644 index 0000000..8ff96bd --- /dev/null +++ b/frontend/src/components/monitor/log-center/unified-query/constants.ts @@ -0,0 +1,23 @@ +// unified-query/constants.ts — UnifiedQueryPanel 静态常量 +// 引擎默认查询语句、徽标配色、默认时间范围 +import type { TimeRange } from '../types-v3'; + +// 默认最近 5 分钟 +export const DEFAULT_TIME_RANGE: TimeRange = { + start: new Date(Date.now() - 5 * 60000).toISOString().replace('T', ' ').substring(0, 19), + end: new Date().toISOString().replace('T', ' ').substring(0, 19), + label: 'Last 5 min', + type: 'relative', +}; + +// 引擎默认查询语句:Loki 用 LogQL / ES 用 Lucene,对齐平台 admin-api 服务 +export const DEFAULT_QUERIES: Record<'loki' | 'elasticsearch', string> = { + loki: '{app="admin-api"} |= "INFO"', + elasticsearch: 'app:admin-api', +}; + +// 引擎徽标:左侧小圆点 + 语言标签 +export const ENGINE_BADGE: Record<'loki' | 'elasticsearch', { label: string; dotColor: string }> = { + loki: { label: 'LogQL', dotColor: '#06b6d4' }, + elasticsearch: { label: 'Lucene', dotColor: '#22c55e' }, +}; diff --git a/frontend/src/components/monitor/log-center/unified-query/entry-mappers.ts b/frontend/src/components/monitor/log-center/unified-query/entry-mappers.ts new file mode 100644 index 0000000..a985d89 --- /dev/null +++ b/frontend/src/components/monitor/log-center/unified-query/entry-mappers.ts @@ -0,0 +1,44 @@ +// unified-query/entry-mappers.ts — LogQueryEntry → LogEntry 映射 + 时间格式化辅助 +// 从 UnifiedQueryPanel 提取的纯函数(无 React 依赖) +import { formatLogTimestamp } from '../log-format'; +import type { LogEntry } from '../types-v3'; +import type { LogQueryEntry } from '@/api'; + +/** + * 将后端 LogQueryEntry 数组映射为前端 LogEntry 数组: + * - 优先使用后端返回的 level 字段 + * - ES 引擎额外尝试从 raw JSON 解析 level + * - 兜底用正则匹配日志级别关键字 + */ +export function mapRawEntries(rawEntries: LogQueryEntry[], effEngine: 'loki' | 'elasticsearch'): LogEntry[] { + return rawEntries.map((e) => { + const raw = String(e.raw || e.message || ''); + let level = String(e.level || e.labels?.detected_level || e.labels?.level || ''); + if (!level && effEngine === 'elasticsearch' && raw) { + try { + const parsed = JSON.parse(raw) as Record; + level = String(parsed.level || parsed['log.level'] || (parsed.log as Record)?.level || ''); + } catch { /* 非 JSON */ } + } + if (!level) { + const levelMatch = raw.match(/\s(INFO|WARN|WARNING|ERROR|DEBUG|TRACE|FATAL)\s/i); + if (levelMatch) level = levelMatch[1].toLowerCase(); + } + const labels = e.labels || {}; + return { + timestamp: formatLogTimestamp(e.timestamp || ''), + level, + message: String(e.message || raw), + labels, + raw, + source: labels.app || labels.service_name || '', + }; + }); +} + +/** + * ISO 时间字符串规范化:补全毫秒和 Z 后缀(ES 7.x strict_date_optional_time 要求 .SSSZ 格式) + */ +export function normalizeISO(time: string): string { + return (time + (time.includes('T') ? '' : '.000Z')).replace(' ', 'T'); +}