refactor(quality): Q13 前端巨型组件拆分 — UnifiedQueryPanel 682L→529L (-22%), 提取 constants + entry-mappers + 5 个 UI 子组件 (QueryControlRow/SecondaryToolbar/LogVolumeChart/ResultsTabs/QueryEmptyStates), 零逻辑变更
This commit is contained in:
1 parent
b2e8fbbfc0
commit
8a11acd086
11 files changed
+654
-270
No files matched your search
@@ -129,7 +129,7 @@
|
|||||||
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
| ~~`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) |
|
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
||||||
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
| ~~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 🟡 中优先级
|
### 3.2 🟡 中优先级
|
||||||
|
|
||||||
|
|||||||
+38
@@ -484,6 +484,7 @@ services/api.ts (1324L)
|
|||||||
| Q10 | 前端巨型组件拆分(ApprovalWorkflowPage 705L→167L,提取 types + UserSelectInline + WorkflowEditorModal + WorkflowCard) | P0 | **done** |
|
| Q10 | 前端巨型组件拆分(ApprovalWorkflowPage 705L→167L,提取 types + UserSelectInline + WorkflowEditorModal + WorkflowCard) | P0 | **done** |
|
||||||
| Q11 | 前端巨型组件拆分(PipelineRunDetail 678L→273L,提取 types + StageProgressBar + RunsTable + LogConsole) | P0 | **done** |
|
| Q11 | 前端巨型组件拆分(PipelineRunDetail 678L→273L,提取 types + StageProgressBar + RunsTable + LogConsole) | P0 | **done** |
|
||||||
| Q12 | 前端巨型组件拆分(CloudServerImport 699L→404L,提取 StepIndicator + 4 个向导步骤组件 + types,any[]→具体接口) | 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] 17/17 集成回归测试通过(零回归)
|
||||||
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
||||||
- [x] 云服务器导入向导功能不变(4 步骤切换/厂商选择/账号选择/区域选择/发现导入全部正常)
|
- [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/分享链接/保存查询/告警创建全部正常)
|
||||||
+49
@@ -1280,3 +1280,52 @@ python3 /srv/aiops/tests/integration_test.py --host 127.0.0.1 --port 8080 | tail
|
|||||||
- [x] 17/17 集成回归测试通过(零回归)
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
||||||
- [x] 云服务器导入向导功能不变(4 步骤切换 / 厂商选择 / 账号选择 / 区域选择搜索+全选 / 主机发现+导入)
|
- [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/分享链接/保存查询/告警创建全部正常)
|
||||||
@@ -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 { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useSearchParams, useLocation } from 'react-router-dom';
|
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 { LogQueryToolbar } from './LogQueryToolbar';
|
||||||
import { StatChartPanel } from './StatChartPanel';
|
|
||||||
import { LogClusterPanel } from './LogClusterPanel';
|
|
||||||
import { SaveQueryDialog, SaveAlertDialog } from './SaveQueryDialog';
|
import { SaveQueryDialog, SaveAlertDialog } from './SaveQueryDialog';
|
||||||
import { QueryHistoryPanel } from './QueryHistoryPanel';
|
import { QueryHistoryPanel } from './QueryHistoryPanel';
|
||||||
import { SavedQueriesPanel } from './SavedQueriesPanel';
|
import { SavedQueriesPanel } from './SavedQueriesPanel';
|
||||||
import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog';
|
import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog';
|
||||||
import { QueryAutocompleteInput } from './QueryAutocompleteInput';
|
|
||||||
import { QuickQueryBar } from './QuickQueryBar';
|
import { QuickQueryBar } from './QuickQueryBar';
|
||||||
import { parseLogQueryUrl, buildLogQueryParams, buildShareUrl } from './query-url-state';
|
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 { copyToClipboard } from '@/utils/clipboard';
|
||||||
import { buildLogCopyText, formatHistogramCount, formatHistogramTick, formatLogTimestamp } from './log-format';
|
import { buildLogCopyText, formatLogTimestamp } from './log-format';
|
||||||
import { EsIndexPicker } from './EsIndexPicker';
|
|
||||||
import { LokiLabelBrowser } from './LokiLabelBrowser';
|
import { LokiLabelBrowser } from './LokiLabelBrowser';
|
||||||
import { applyESFieldFilter, applyLokiLabelFilter, extractLokiSelector } from './query-builder';
|
import { applyESFieldFilter, applyLokiLabelFilter, extractLokiSelector } from './query-builder';
|
||||||
import type { LogEngine, TimeRange, ResultTab, ViewMode, LogEntry, LogField, StatTable, LogCluster, QueryHistoryItem, SavedQuery } from './types-v3';
|
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 { ENGINE_META, buildStatTableFromEntries, buildClustersFromEntries, extractFields, generateHistogramData, historyStorage } from './types-v3';
|
||||||
|
import { extractCommonLabels } from './EnhancedLogResults';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getErrorMessage } from '@/lib/utils';
|
import { getErrorMessage } from '@/lib/utils';
|
||||||
|
// 提取的子组件 + 常量 + 工具函数
|
||||||
// 默认最近5分钟 // Logan.Li
|
import { DEFAULT_TIME_RANGE, DEFAULT_QUERIES, ENGINE_BADGE } from './unified-query/constants';
|
||||||
const DEFAULT_TIME_RANGE: TimeRange = {
|
import { mapRawEntries, normalizeISO } from './unified-query/entry-mappers';
|
||||||
start: new Date(Date.now() - 5 * 60000).toISOString().replace('T', ' ').substring(0, 19),
|
import { QueryControlRow } from './unified-query/QueryControlRow';
|
||||||
end: new Date().toISOString().replace('T', ' ').substring(0, 19),
|
import { SecondaryToolbar } from './unified-query/SecondaryToolbar';
|
||||||
label: 'Last 5 min',
|
import { LogVolumeChart } from './unified-query/LogVolumeChart';
|
||||||
type: 'relative',
|
import { ResultsTabs } from './unified-query/ResultsTabs';
|
||||||
};
|
import { QueryEmptyStates } from './unified-query/QueryEmptyStates';
|
||||||
|
|
||||||
// 引擎默认查询语句 // 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' },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props { engine: LogEngine; onRuleCreated: () => void; }
|
interface Props { engine: LogEngine; onRuleCreated: () => void; }
|
||||||
|
|
||||||
@@ -62,7 +43,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
const meta = ENGINE_META[effEngine];
|
const meta = ENGINE_META[effEngine];
|
||||||
const badge = ENGINE_BADGE[effEngine];
|
const badge = ENGINE_BADGE[effEngine];
|
||||||
|
|
||||||
// 从后端加载真实数据源(无 demo 兜底,无源时引导用户前往日志库配置)// Logan.Li
|
// 从后端加载真实数据源(无 demo 兜底,无源时引导用户前往日志库配置)
|
||||||
const [sources, setSources] = useState<LogDataSource[]>([]);
|
const [sources, setSources] = useState<LogDataSource[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logCenterApi.listDatasources(effEngine).then((res) => {
|
logCenterApi.listDatasources(effEngine).then((res) => {
|
||||||
@@ -73,9 +54,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
|
|
||||||
const [query, setQuery] = useState(DEFAULT_QUERIES[effEngine]);
|
const [query, setQuery] = useState(DEFAULT_QUERIES[effEngine]);
|
||||||
const [dsId, setDsId] = useState(String(sources[0]?.id || ''));
|
const [dsId, setDsId] = useState(String(sources[0]?.id || ''));
|
||||||
// ES 查询允许在面板内临时覆盖索引(默认取自数据源配置)// Logan.Li
|
|
||||||
const [esIndex, setEsIndex] = useState('');
|
const [esIndex, setEsIndex] = useState('');
|
||||||
// API 单次查询返回条数上限(后端限制 1000),Loki 用 limit / ES 用 size // Logan.Li
|
|
||||||
const [queryLimit, setQueryLimit] = useState(1000);
|
const [queryLimit, setQueryLimit] = useState(1000);
|
||||||
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
|
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -116,7 +95,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
|
|
||||||
const currentSource = sources.find((s) => String(s.id) === dsId);
|
const currentSource = sources.find((s) => String(s.id) === dsId);
|
||||||
|
|
||||||
// 数据源加载后选中默认或首个;首次从 URL 恢复查询状态 // Logan.Li
|
// 数据源加载后选中默认或首个;首次从 URL 恢复查询状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sources.length === 0) return;
|
if (sources.length === 0) return;
|
||||||
if (!urlBootstrapped.current) {
|
if (!urlBootstrapped.current) {
|
||||||
@@ -134,19 +113,16 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
if (!dsId && def) setDsId(String(def.id));
|
if (!dsId && def) setDsId(String(def.id));
|
||||||
}, [sources, searchParams, dsId]);
|
}, [sources, searchParams, dsId]);
|
||||||
|
|
||||||
// 查询状态同步到 URL(可分享)// Logan.Li
|
// 查询状态同步到 URL(可分享)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!urlBootstrapped.current) return;
|
if (!urlBootstrapped.current) return;
|
||||||
const p = buildLogQueryParams({
|
const p = buildLogQueryParams({
|
||||||
query,
|
query, datasourceId: dsId || undefined, index: esIndex || undefined, timeRange,
|
||||||
datasourceId: dsId || undefined,
|
|
||||||
index: esIndex || undefined,
|
|
||||||
timeRange,
|
|
||||||
});
|
});
|
||||||
setSearchParams(p, { replace: true });
|
setSearchParams(p, { replace: true });
|
||||||
}, [query, dsId, esIndex, timeRange, setSearchParams]);
|
}, [query, dsId, esIndex, timeRange, setSearchParams]);
|
||||||
|
|
||||||
// Loki 标签名(自动补全)// Logan.Li
|
// Loki 标签名(自动补全)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (effEngine !== 'loki' || !currentSource?.id) return;
|
if (effEngine !== 'loki' || !currentSource?.id) return;
|
||||||
logCenterApi.listLokiLabels(currentSource.id, timeIsoParams())
|
logCenterApi.listLokiLabels(currentSource.id, timeIsoParams())
|
||||||
@@ -154,7 +130,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
.catch(() => setLabelNames([]));
|
.catch(() => setLabelNames([]));
|
||||||
}, [effEngine, currentSource?.id, timeIsoParams]);
|
}, [effEngine, currentSource?.id, timeIsoParams]);
|
||||||
|
|
||||||
// ES mapping 索引字段(Kibana 风格侧栏)// Logan.Li
|
// ES mapping 索引字段(Kibana 风格侧栏)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (effEngine !== 'elasticsearch' || !currentSource?.id) return;
|
if (effEngine !== 'elasticsearch' || !currentSource?.id) return;
|
||||||
logCenterApi.esMapping({ datasource_id: currentSource.id, index: esIndex || undefined })
|
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 fields = useMemo(() => extractFields(entries), [entries]);
|
||||||
const commonLabels = useMemo(() => extractCommonLabels(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 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 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<string, unknown>;
|
|
||||||
level = String(parsed.level || parsed['log.level'] || (parsed.log as Record<string, unknown>)?.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) => {
|
const fetchHistogram = useCallback(async (dsIdNum: number, startISO: string, endISO: string) => {
|
||||||
try {
|
try {
|
||||||
if (effEngine === 'loki') {
|
if (effEngine === 'loki') {
|
||||||
const res = await logCenterApi.lokiHistogram({ datasource_id: dsIdNum, query, start: startISO, end: endISO });
|
const res = await logCenterApi.lokiHistogram({ datasource_id: dsIdNum, query, start: startISO, end: endISO });
|
||||||
const buckets = res?.buckets || [];
|
const buckets = res?.buckets || [];
|
||||||
if (buckets.length > 0) {
|
if (buckets.length > 0) {
|
||||||
setHistogramData(buckets.map((b) => ({
|
setHistogramData(buckets.map((b) => ({ timestamp: formatLogTimestamp(b.timestamp), count: b.count })));
|
||||||
timestamp: formatLogTimestamp(b.timestamp),
|
|
||||||
count: b.count,
|
|
||||||
})));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const res = await logCenterApi.esHistogram({
|
const res = await logCenterApi.esHistogram({ datasource_id: dsIdNum, query, index: esIndex || undefined, start: startISO, end: endISO });
|
||||||
datasource_id: dsIdNum, query, index: esIndex || undefined, start: startISO, end: endISO,
|
|
||||||
});
|
|
||||||
const buckets = res?.buckets || [];
|
const buckets = res?.buckets || [];
|
||||||
if (buckets.length > 0) {
|
if (buckets.length > 0) {
|
||||||
setHistogramData(buckets.map((b) => ({
|
setHistogramData(buckets.map((b) => ({ timestamp: formatLogTimestamp(b.timestamp), count: b.count })));
|
||||||
timestamp: formatLogTimestamp(b.timestamp),
|
|
||||||
count: b.count,
|
|
||||||
})));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* 直方图失败时回退客户端聚合 */ }
|
} catch { /* 直方图失败时回退客户端聚合 */ }
|
||||||
}, [effEngine, query, esIndex]);
|
}, [effEngine, query, esIndex]);
|
||||||
|
|
||||||
// 执行查询 — 接入真实 API(无 mock)// Logan.Li
|
// 执行查询 — 接入真实 API(无 mock)
|
||||||
const handleRun = useCallback(async () => {
|
const handleRun = useCallback(async () => {
|
||||||
// 空查询兜底:后端 Query 字段 required,空值会 400,这里提前拦截 // Logan.Li
|
|
||||||
if (!currentSource || !query.trim()) return;
|
if (!currentSource || !query.trim()) return;
|
||||||
const dsIdNum = currentSource.id;
|
const dsIdNum = currentSource.id;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -238,8 +184,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const startISO = (timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z')).replace(' ', 'T');
|
const startISO = normalizeISO(timeRange.start);
|
||||||
const endISO = (timeRange.end + (timeRange.end.includes('T') ? '' : '.000Z')).replace(' ', 'T');
|
const endISO = normalizeISO(timeRange.end);
|
||||||
|
|
||||||
const data = effEngine === 'loki'
|
const data = effEngine === 'loki'
|
||||||
? await logCenterApi.queryLoki({
|
? await logCenterApi.queryLoki({
|
||||||
@@ -250,7 +196,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rawEntries = data?.entries || [];
|
const rawEntries = data?.entries || [];
|
||||||
const mapped = mapRawEntries(rawEntries);
|
const mapped = mapRawEntries(rawEntries, effEngine);
|
||||||
|
|
||||||
setEntries(mapped);
|
setEntries(mapped);
|
||||||
void fetchHistogram(dsIdNum, startISO, endISO);
|
void fetchHistogram(dsIdNum, startISO, endISO);
|
||||||
@@ -277,14 +223,14 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 () => {
|
const handleLoadMore = useCallback(async () => {
|
||||||
if (!currentSource || !hasMore || loadingMore) return;
|
if (!currentSource || !hasMore || loadingMore) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const startISO = (timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z')).replace(' ', 'T');
|
const startISO = normalizeISO(timeRange.start);
|
||||||
const endISO = (timeRange.end + (timeRange.end.includes('T') ? '' : '.000Z')).replace(' ', 'T');
|
const endISO = normalizeISO(timeRange.end);
|
||||||
const dsIdNum = currentSource.id;
|
const dsIdNum = currentSource.id;
|
||||||
const data = effEngine === 'loki'
|
const data = effEngine === 'loki'
|
||||||
? await logCenterApi.queryLoki({
|
? await logCenterApi.queryLoki({
|
||||||
@@ -295,7 +241,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
datasource_id: dsIdNum, index: esIndex || undefined, query, size: queryLimit,
|
datasource_id: dsIdNum, index: esIndex || undefined, query, size: queryLimit,
|
||||||
start: startISO, end: endISO, search_after: searchAfter,
|
start: startISO, end: endISO, search_after: searchAfter,
|
||||||
});
|
});
|
||||||
const more = mapRawEntries(data?.entries || []);
|
const more = mapRawEntries(data?.entries || [], effEngine);
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
const merged = [...prev, ...more];
|
const merged = [...prev, ...more];
|
||||||
setStatTable(buildStatTableFromEntries(merged));
|
setStatTable(buildStatTableFromEntries(merged));
|
||||||
@@ -311,7 +257,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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') => {
|
const handleLabelFilter = useCallback((key: string, value: string, op: 'include' | 'exclude') => {
|
||||||
if (effEngine === 'loki') {
|
if (effEngine === 'loki') {
|
||||||
@@ -375,7 +321,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
: await logCenterApi.queryElasticsearch({
|
: await logCenterApi.queryElasticsearch({
|
||||||
datasource_id: currentSource.id, index: esIndex || undefined, query, size: 200, start: startISO, end: endISO,
|
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) => {
|
setEntries((prev) => {
|
||||||
const keys = new Set(prev.map((e) => `${e.timestamp}|${e.message}`));
|
const keys = new Set(prev.map((e) => `${e.timestamp}|${e.message}`));
|
||||||
const fresh = mapped.filter((e) => !keys.has(`${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;
|
return merged;
|
||||||
});
|
});
|
||||||
} catch { /* Live tail 静默失败 */ }
|
} catch { /* Live tail 静默失败 */ }
|
||||||
}, [currentSource, query, effEngine, esIndex, mapRawEntries]);
|
}, [currentSource, query, effEngine, esIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!liveTail) return undefined;
|
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 toggleField = useCallback((n: string) => setSelectedFields(p => p.includes(n) ? p.filter(f => f !== n) : [...p, n]), []);
|
||||||
const handleSaveMenu = (a: 'query' | 'alert') => {
|
const handleSaveMenu = (a: 'query' | 'alert') => {
|
||||||
setSaveMenuOpen(false);
|
setSaveMenuOpen(false);
|
||||||
if (a === 'query') {
|
if (a === 'query') setSaveQueryOpen(true);
|
||||||
setSaveQueryOpen(true);
|
else setSaveAlertOpen(true);
|
||||||
} else {
|
|
||||||
setSaveAlertOpen(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChartClick = useCallback((st: { activeLabel?: string }) => {
|
const handleChartClick = useCallback((st: { activeLabel?: string }) => {
|
||||||
@@ -460,99 +403,45 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* ===== 查询控制行 ===== */}
|
<QueryControlRow
|
||||||
<div className="flex items-center gap-2">
|
badge={badge}
|
||||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
query={query}
|
||||||
<div className="flex items-center gap-1.5 px-2.5 h-10 rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)' }}>
|
onQueryChange={setQuery}
|
||||||
<span className="w-2 h-2 rounded-full" style={{ background: badge.dotColor }} />
|
effEngine={effEngine}
|
||||||
<span className="text-[11px] font-semibold" style={{ color: badge.dotColor }}>{badge.label}</span>
|
metaQueryPlaceholder={meta.queryPlaceholder}
|
||||||
</div>
|
|
||||||
<QueryAutocompleteInput
|
|
||||||
value={query}
|
|
||||||
onChange={setQuery}
|
|
||||||
engine={effEngine}
|
|
||||||
placeholder={meta.queryPlaceholder}
|
|
||||||
labelNames={labelNames}
|
labelNames={labelNames}
|
||||||
labelValues={labelValues}
|
labelValues={labelValues}
|
||||||
mappingFields={mappingFieldNames}
|
mappingFields={mappingFieldNames}
|
||||||
onRequestLabelValues={handleRequestLabelValues}
|
onRequestLabelValues={handleRequestLabelValues}
|
||||||
|
loading={loading}
|
||||||
|
liveTail={liveTail}
|
||||||
|
hasSource={!!currentSource}
|
||||||
|
onRun={() => { setLiveTail(false); void handleRun(); }}
|
||||||
|
onToggleLiveTail={() => setLiveTail((v) => !v)}
|
||||||
/>
|
/>
|
||||||
<button onClick={() => { setLiveTail(false); void handleRun(); }} disabled={loading || liveTail || !currentSource || !query.trim()} className="h-10 px-4 rounded text-xs font-medium text-white flex items-center gap-1.5 flex-shrink-0 transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed" style={{ background: '#22c55e' }}>
|
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} {t('monitor.logPanel.query')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setLiveTail((v) => !v)}
|
|
||||||
disabled={!currentSource || !query.trim()}
|
|
||||||
className="h-10 px-3 rounded text-xs flex items-center gap-1 flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
border: `1px solid ${liveTail ? '#ef4444' : 'var(--dg-border)'}`,
|
|
||||||
color: liveTail ? '#ef4444' : 'var(--dg-text-secondary)',
|
|
||||||
background: liveTail ? 'rgba(239,68,68,0.1)' : 'var(--dg-bg-tertiary)',
|
|
||||||
}}
|
|
||||||
title={t('monitor.logPanel.liveTailTitle')}
|
|
||||||
>
|
|
||||||
<Radio className={`h-3.5 w-3.5 ${liveTail ? 'animate-pulse' : ''}`} /> Tail
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 次级控件行(时间 / 数据源 / 索引 / 上限 / 另存为 / 历史)// Logan.Li */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<TimeRangePicker value={timeRange} onChange={setTimeRange} />
|
|
||||||
|
|
||||||
<select value={dsId} onChange={e => setDsId(e.target.value)} className="h-10 px-2 text-xs rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
|
<SecondaryToolbar
|
||||||
{sources.length === 0 && <option value="">{t('monitor.logPanel.noDatasource', { name: meta.name })}</option>}
|
timeRange={timeRange}
|
||||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? t('monitor.logPanel.defaultSuffix') : ''}</option>)}
|
onTimeRangeChange={setTimeRange}
|
||||||
</select>
|
dsId={dsId}
|
||||||
|
onDsIdChange={setDsId}
|
||||||
{effEngine === 'elasticsearch' && (
|
sources={sources}
|
||||||
<EsIndexPicker
|
engineName={meta.name}
|
||||||
datasourceId={currentSource?.id}
|
effEngine={effEngine}
|
||||||
value={esIndex}
|
currentSourceId={currentSource?.id}
|
||||||
onChange={setEsIndex}
|
esIndex={esIndex}
|
||||||
|
onEsIndexChange={setEsIndex}
|
||||||
|
queryLimit={queryLimit}
|
||||||
|
onQueryLimitChange={setQueryLimit}
|
||||||
|
saveMenuOpen={saveMenuOpen}
|
||||||
|
setSaveMenuOpen={setSaveMenuOpen}
|
||||||
|
onSaveMenuSelect={handleSaveMenu}
|
||||||
|
onShareLink={() => { void handleShareLink(); }}
|
||||||
|
onOpenSaved={() => setSavedPanelOpen(true)}
|
||||||
|
onOpenHistory={() => setHistoryPanelOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 h-10 px-2 rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)' }}>
|
{/* 无数据源时引导用户前往日志库配置 */}
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{t('monitor.logPanel.limit')}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={1000}
|
|
||||||
value={queryLimit}
|
|
||||||
onChange={e => {
|
|
||||||
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)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex-shrink-0">
|
|
||||||
<button onClick={() => setSaveMenuOpen(!saveMenuOpen)} className="h-10 px-3 rounded text-xs flex items-center gap-1" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}>
|
|
||||||
<Save className="h-3.5 w-3.5" /> {t('monitor.logPanel.saveAs')} <ChevronDown className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
{saveMenuOpen && (<>
|
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setSaveMenuOpen(false)} />
|
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
|
||||||
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> {t('monitor.logPanel.saveAsQueryItem')}</button>
|
|
||||||
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> {t('monitor.logPanel.saveAsAlertItem')}</button>
|
|
||||||
</div>
|
|
||||||
</>)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={() => { void handleShareLink(); }} className="h-10 px-2.5 flex items-center gap-1 rounded flex-shrink-0 text-xs" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title={t('monitor.logPanel.shareLinkTitle')}>
|
|
||||||
<Link2 className="h-3.5 w-3.5" /> {t('monitor.logPanel.share')}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setSavedPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title={t('monitor.logPanel.savedQueries')}>
|
|
||||||
<Bookmark className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setHistoryPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title={t('monitor.logPanel.history')}>
|
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 无数据源时引导用户前往日志库配置 // Logan.Li */}
|
|
||||||
{sources.length === 0 && (
|
{sources.length === 0 && (
|
||||||
<div className="p-3 rounded border text-xs" style={{ background: 'rgba(245,158,11,0.1)', borderColor: 'rgba(245,158,11,0.3)', color: '#f59e0b' }}>
|
<div className="p-3 rounded border text-xs" style={{ background: 'rgba(245,158,11,0.1)', borderColor: 'rgba(245,158,11,0.3)', color: '#f59e0b' }}>
|
||||||
{t('monitor.logPanel.noDatasourceHint', { name: meta.name })}
|
{t('monitor.logPanel.noDatasourceHint', { name: meta.name })}
|
||||||
@@ -573,103 +462,61 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
|
|
||||||
{/* 统计条 + 工具栏 */}
|
{/* 统计条 + 工具栏 */}
|
||||||
{entries.length > 0 && !loading && (
|
{entries.length > 0 && !loading && (
|
||||||
<LogQueryToolbar total={totalCount} queryTime={queryTime} perPage={perPage} onPerPageChange={n => { 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(); }} />
|
<LogQueryToolbar
|
||||||
|
total={totalCount} queryTime={queryTime} perPage={perPage}
|
||||||
|
onPerPageChange={n => { 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 面积图 */}
|
{/* Log Volume 面积图 */}
|
||||||
{entries.length > 0 && (
|
{entries.length > 0 && (
|
||||||
<div className="h-[120px] rounded-lg border cursor-crosshair" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
<LogVolumeChart effEngine={effEngine} histogramData={histogramData} color={meta.color} onChartClick={handleChartClick} />
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={histogramData} margin={{ top: 6, right: 8, bottom: 0, left: 0 }} onClick={handleChartClick}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id={`vol-${effEngine}`} x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor={meta.color} stopOpacity={0.35} />
|
|
||||||
<stop offset="100%" stopColor={meta.color} stopOpacity={0.02} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" vertical={false} />
|
|
||||||
<XAxis dataKey="timestamp" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={{ stroke: 'var(--dg-border)' }} tickLine={false} tickFormatter={formatHistogramTick} interval="preserveStartEnd" />
|
|
||||||
<YAxis tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={false} tickLine={false} width={36} tickFormatter={formatHistogramCount} />
|
|
||||||
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} formatter={(v: number) => [`${v} ${t('monitor.logPanel.chartCountSuffix')}`, t('monitor.logPanel.chartCountLabel')]} labelFormatter={(l: string) => `${t('monitor.logPanel.chartTimePrefix')} ${l}`} />
|
|
||||||
<Area type="monotone" dataKey="count" stroke={meta.color} fill={`url(#vol-${effEngine})`} strokeWidth={1.5} dot={false} activeDot={{ r: 4, stroke: meta.color, strokeWidth: 2, fill: 'var(--dg-bg-secondary)' }} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 结果标签页 */}
|
{/* 结果标签页 */}
|
||||||
{entries.length > 0 && (
|
{entries.length > 0 && (
|
||||||
<div className="rounded-lg border" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
<ResultsTabs
|
||||||
<div className="flex items-center gap-0.5 px-2 pt-1.5" style={{ borderBottom: '1px solid var(--dg-border)' }}>
|
resultTab={resultTab}
|
||||||
{(['logs', 'stats', 'cluster'] as ResultTab[]).map(tab => {
|
setResultTab={setResultTab}
|
||||||
const labels: Record<ResultTab, string> = { logs: t('monitor.logPanel.tabLogs'), stats: t('monitor.logPanel.tabStats'), cluster: t('monitor.logPanel.tabCluster') };
|
effEngine={effEngine}
|
||||||
return (
|
metaColor={meta.color}
|
||||||
<button key={tab} onClick={() => setResultTab(tab)} className="px-3 py-1.5 text-[11px] font-medium rounded-t-md transition-colors"
|
fields={fields}
|
||||||
style={{ color: resultTab === tab ? meta.color : 'var(--dg-text-muted)', background: resultTab === tab ? 'var(--dg-bg-secondary)' : 'transparent', borderBottom: resultTab === tab ? `2px solid ${meta.color}` : '2px solid transparent', marginBottom: '-1px' }}>
|
indexedFields={indexedFields}
|
||||||
{labels[tab]}
|
selectedFields={selectedFields}
|
||||||
</button>
|
onFieldClick={handleFieldClick}
|
||||||
);
|
onToggleDisplayField={toggleField}
|
||||||
})}
|
loading={loading}
|
||||||
</div>
|
pagedEntries={pagedEntries}
|
||||||
|
viewMode={viewMode}
|
||||||
<div className="p-2">
|
setViewMode={setViewMode}
|
||||||
{resultTab === 'logs' && (<>
|
wrapLines={wrapLines}
|
||||||
<div className="flex gap-3">
|
setWrapLines={setWrapLines}
|
||||||
<LogFieldSidebar fields={fields} indexedFields={indexedFields} selectedFields={selectedFields} onFieldClick={handleFieldClick} onToggleDisplayField={toggleField} loading={loading} />
|
truncate={truncate}
|
||||||
<div className="flex-1 min-w-0">
|
setTruncate={setTruncate}
|
||||||
<EnhancedLogResults entries={pagedEntries} loading={loading} viewMode={viewMode} wrapLines={wrapLines} truncate={truncate} commonLabels={commonLabels || undefined} onLabelFilter={handleLabelFilter} />
|
commonLabels={commonLabels}
|
||||||
</div>
|
onLabelFilter={handleLabelFilter}
|
||||||
</div>
|
statTable={statTable}
|
||||||
<div className="flex items-center gap-3 py-2 px-1">
|
clusters={clusters}
|
||||||
<div className="flex rounded border overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
|
query={query}
|
||||||
{(['raw', 'field', 'table'] as const).map(m => (
|
onSaveAsAlert={() => setSaveAlertOpen(true)}
|
||||||
<button key={m} onClick={() => setViewMode(m)} className="px-3 py-1 text-[11px] transition-colors" style={{ background: viewMode === m ? meta.color : 'var(--dg-bg-tertiary)', color: viewMode === m ? '#fff' : 'var(--dg-text-muted)' }}>
|
onSaveAsQuery={() => setSaveQueryOpen(true)}
|
||||||
{m === 'raw' ? t('monitor.logPanel.viewRaw') : m === 'field' ? t('monitor.logPanel.viewField') : t('monitor.logPanel.viewTable')}
|
/>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor.logPanel.truncateTitle')}><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor.logPanel.truncate')}</label>
|
|
||||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor.logPanel.wrapLinesTitle')}><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor.logPanel.wrapLines')}</label>
|
|
||||||
</div>
|
|
||||||
</>)}
|
|
||||||
{resultTab === 'stats' && <StatChartPanel statTable={statTable} engine={effEngine as LogEngine} query={query} onSaveAsAlert={() => setSaveAlertOpen(true)} onSaveAsQuery={() => setSaveQueryOpen(true)} />}
|
|
||||||
{resultTab === 'cluster' && <LogClusterPanel clusters={clusters} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 空状态:从未查询 vs 查询返回 0 结果 */}
|
{/* 空状态 */}
|
||||||
{!loading && entries.length === 0 && !hasQueried && (
|
<QueryEmptyStates
|
||||||
<div className="border rounded-lg flex flex-col items-center justify-center gap-2" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
|
loading={loading}
|
||||||
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logPanel.emptyHint')}</span>
|
entriesCount={entries.length}
|
||||||
<span className="text-xs font-mono px-3 py-1 rounded" style={{ color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>{meta.queryPlaceholder}</span>
|
hasQueried={hasQueried}
|
||||||
</div>
|
queryTime={queryTime}
|
||||||
)}
|
metaQueryPlaceholder={meta.queryPlaceholder}
|
||||||
{!loading && entries.length === 0 && hasQueried && (
|
onRequery={handleRun}
|
||||||
<div className="border rounded-lg flex flex-col items-center justify-center gap-3" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
|
/>
|
||||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--dg-bg-tertiary)' }}>
|
|
||||||
<svg className="h-6 w-6" style={{ color: 'var(--dg-text-muted)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor.logPanel.noMatch')}</p>
|
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--dg-text-muted)' }}>
|
|
||||||
{t('monitor.logPanel.noMatchDesc', { ms: queryTime })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={handleRun} className="px-4 py-1.5 rounded text-xs font-medium text-white transition-opacity hover:opacity-90" style={{ background: '#22c55e' }}>
|
|
||||||
<Play className="h-3.5 w-3.5 inline mr-1" />{t('monitor.logPanel.requeries')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{loading && entries.length === 0 && (
|
|
||||||
<div className="border rounded-lg flex items-center justify-center" style={{ borderColor: 'var(--dg-border)', minHeight: '200px' }}>
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin" style={{ color: 'var(--dg-text-muted)' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 对话框 */}
|
{/* 对话框 */}
|
||||||
<SaveQueryDialog open={saveQueryOpen} onClose={() => setSaveQueryOpen(false)} engine={effEngine as LogEngine} query={query} timeRange={timeRange} datasourceId={currentSource?.id} datasourceName={currentSource?.name} />
|
<SaveQueryDialog open={saveQueryOpen} onClose={() => setSaveQueryOpen(false)} engine={effEngine as LogEngine} query={query} timeRange={timeRange} datasourceId={currentSource?.id} datasourceName={currentSource?.name} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="h-[120px] rounded-lg border cursor-crosshair" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={histogramData} margin={{ top: 6, right: 8, bottom: 0, left: 0 }} onClick={onChartClick}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`vol-${effEngine}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={color} stopOpacity={0.35} />
|
||||||
|
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" vertical={false} />
|
||||||
|
<XAxis dataKey="timestamp" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={{ stroke: 'var(--dg-border)' }} tickLine={false} tickFormatter={formatHistogramTick} interval="preserveStartEnd" />
|
||||||
|
<YAxis tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={false} tickLine={false} width={36} tickFormatter={formatHistogramCount} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }}
|
||||||
|
formatter={(v: number) => [`${v} ${t('monitor.logPanel.chartCountSuffix')}`, t('monitor.logPanel.chartCountLabel')]}
|
||||||
|
labelFormatter={(l: string) => `${t('monitor.logPanel.chartTimePrefix')} ${l}`}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="count" stroke={color} fill={`url(#vol-${effEngine})`} strokeWidth={1.5} dot={false} activeDot={{ r: 4, stroke: color, strokeWidth: 2, fill: 'var(--dg-bg-secondary)' }} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 px-2.5 h-10 rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)' }}>
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ background: badge.dotColor }} />
|
||||||
|
<span className="text-[11px] font-semibold" style={{ color: badge.dotColor }}>{badge.label}</span>
|
||||||
|
</div>
|
||||||
|
<QueryAutocompleteInput
|
||||||
|
value={query}
|
||||||
|
onChange={onQueryChange}
|
||||||
|
engine={effEngine}
|
||||||
|
placeholder={metaQueryPlaceholder}
|
||||||
|
labelNames={labelNames}
|
||||||
|
labelValues={labelValues}
|
||||||
|
mappingFields={mappingFields}
|
||||||
|
onRequestLabelValues={onRequestLabelValues}
|
||||||
|
/>
|
||||||
|
<button onClick={onRun} disabled={loading || liveTail || !canQuery}
|
||||||
|
className="h-10 px-4 rounded text-xs font-medium text-white flex items-center gap-1.5 flex-shrink-0 transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{ background: '#22c55e' }}>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} {t('monitor.logPanel.query')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onToggleLiveTail}
|
||||||
|
disabled={!canQuery}
|
||||||
|
className="h-10 px-3 rounded text-xs flex items-center gap-1 flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${liveTail ? '#ef4444' : 'var(--dg-border)'}`,
|
||||||
|
color: liveTail ? '#ef4444' : 'var(--dg-text-secondary)',
|
||||||
|
background: liveTail ? 'rgba(239,68,68,0.1)' : 'var(--dg-bg-tertiary)',
|
||||||
|
}}
|
||||||
|
title={t('monitor.logPanel.liveTailTitle')}
|
||||||
|
>
|
||||||
|
<Radio className={`h-3.5 w-3.5 ${liveTail ? 'animate-pulse' : ''}`} /> Tail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="border rounded-lg flex flex-col items-center justify-center gap-2" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logPanel.emptyHint')}</span>
|
||||||
|
<span className="text-xs font-mono px-3 py-1 rounded" style={{ color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>{metaQueryPlaceholder}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询返回 0 结果
|
||||||
|
if (!loading && entriesCount === 0 && hasQueried) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg flex flex-col items-center justify-center gap-3" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
|
||||||
|
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--dg-bg-tertiary)' }}>
|
||||||
|
<svg className="h-6 w-6" style={{ color: 'var(--dg-text-muted)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor.logPanel.noMatch')}</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--dg-text-muted)' }}>
|
||||||
|
{t('monitor.logPanel.noMatchDesc', { ms: queryTime })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onRequery}
|
||||||
|
className="px-4 py-1.5 rounded text-xs font-medium text-white transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: '#22c55e' }}>
|
||||||
|
<Play className="h-3.5 w-3.5 inline mr-1" />{t('monitor.logPanel.requeries')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (loading && entriesCount === 0) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg flex items-center justify-center" style={{ borderColor: 'var(--dg-border)', minHeight: '200px' }}>
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" style={{ color: 'var(--dg-text-muted)' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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<typeof import('../EnhancedLogResults').extractCommonLabels>;
|
||||||
|
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<ResultTab, string> = {
|
||||||
|
logs: t('monitor.logPanel.tabLogs'),
|
||||||
|
stats: t('monitor.logPanel.tabStats'),
|
||||||
|
cluster: t('monitor.logPanel.tabCluster'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||||
|
<div className="flex items-center gap-0.5 px-2 pt-1.5" style={{ borderBottom: '1px solid var(--dg-border)' }}>
|
||||||
|
{(['logs', 'stats', 'cluster'] as ResultTab[]).map(tab => (
|
||||||
|
<button key={tab} onClick={() => setResultTab(tab)}
|
||||||
|
className="px-3 py-1.5 text-[11px] font-medium rounded-t-md transition-colors"
|
||||||
|
style={{
|
||||||
|
color: resultTab === tab ? metaColor : 'var(--dg-text-muted)',
|
||||||
|
background: resultTab === tab ? 'var(--dg-bg-secondary)' : 'transparent',
|
||||||
|
borderBottom: resultTab === tab ? `2px solid ${metaColor}` : '2px solid transparent',
|
||||||
|
marginBottom: '-1px',
|
||||||
|
}}>
|
||||||
|
{labels[tab]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2">
|
||||||
|
{resultTab === 'logs' && (<>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<LogFieldSidebar fields={fields} indexedFields={indexedFields} selectedFields={selectedFields} onFieldClick={onFieldClick} onToggleDisplayField={onToggleDisplayField} loading={loading} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<EnhancedLogResults entries={pagedEntries} loading={loading} viewMode={viewMode} wrapLines={wrapLines} truncate={truncate} commonLabels={commonLabels || undefined} onLabelFilter={onLabelFilter} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 py-2 px-1">
|
||||||
|
<div className="flex rounded border overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
|
||||||
|
{(['raw', 'field', 'table'] as const).map(m => (
|
||||||
|
<button key={m} onClick={() => setViewMode(m)}
|
||||||
|
className="px-3 py-1 text-[11px] transition-colors"
|
||||||
|
style={{ background: viewMode === m ? metaColor : 'var(--dg-bg-tertiary)', color: viewMode === m ? '#fff' : 'var(--dg-text-muted)' }}>
|
||||||
|
{m === 'raw' ? t('monitor.logPanel.viewRaw') : m === 'field' ? t('monitor.logPanel.viewField') : t('monitor.logPanel.viewTable')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor.logPanel.truncateTitle')}>
|
||||||
|
<input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor.logPanel.truncate')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor.logPanel.wrapLinesTitle')}>
|
||||||
|
<input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor.logPanel.wrapLines')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
{resultTab === 'stats' && <StatChartPanel statTable={statTable} engine={effEngine} query={query} onSaveAsAlert={onSaveAsAlert} onSaveAsQuery={onSaveAsQuery} />}
|
||||||
|
{resultTab === 'cluster' && <LogClusterPanel clusters={clusters} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<TimeRangePicker value={timeRange} onChange={onTimeRangeChange} />
|
||||||
|
|
||||||
|
<select value={dsId} onChange={e => onDsIdChange(e.target.value)}
|
||||||
|
className="h-10 px-2 text-xs rounded flex-shrink-0"
|
||||||
|
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
|
||||||
|
{sources.length === 0 && <option value="">{t('monitor.logPanel.noDatasource', { name: engineName })}</option>}
|
||||||
|
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? t('monitor.logPanel.defaultSuffix') : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{effEngine === 'elasticsearch' && (
|
||||||
|
<EsIndexPicker datasourceId={currentSourceId} value={esIndex} onChange={onEsIndexChange} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 h-10 px-2 rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)' }}>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">{t('monitor.logPanel.limit')}</span>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={1000} value={queryLimit}
|
||||||
|
onChange={e => {
|
||||||
|
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)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<button onClick={() => setSaveMenuOpen(!saveMenuOpen)}
|
||||||
|
className="h-10 px-3 rounded text-xs flex items-center gap-1"
|
||||||
|
style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}>
|
||||||
|
<Save className="h-3.5 w-3.5" /> {t('monitor.logPanel.saveAs')} <ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
{saveMenuOpen && (<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setSaveMenuOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-xl overflow-hidden"
|
||||||
|
style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||||
|
<button onClick={() => onSaveMenuSelect('query')}
|
||||||
|
className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80"
|
||||||
|
style={{ color: 'var(--dg-text-secondary)' }}>
|
||||||
|
<Save className="h-3.5 w-3.5" /> {t('monitor.logPanel.saveAsQueryItem')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onSaveMenuSelect('alert')}
|
||||||
|
className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80"
|
||||||
|
style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}>
|
||||||
|
<Bell className="h-3.5 w-3.5" /> {t('monitor.logPanel.saveAsAlertItem')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={onShareLink}
|
||||||
|
className="h-10 px-2.5 flex items-center gap-1 rounded flex-shrink-0 text-xs"
|
||||||
|
style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
|
||||||
|
title={t('monitor.logPanel.shareLinkTitle')}>
|
||||||
|
<Link2 className="h-3.5 w-3.5" /> {t('monitor.logPanel.share')}
|
||||||
|
</button>
|
||||||
|
<button onClick={onOpenSaved}
|
||||||
|
className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0"
|
||||||
|
style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
|
||||||
|
title={t('monitor.logPanel.savedQueries')}>
|
||||||
|
<Bookmark className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={onOpenHistory}
|
||||||
|
className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0"
|
||||||
|
style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
|
||||||
|
title={t('monitor.logPanel.history')}>
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
};
|
||||||
@@ -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<string, unknown>;
|
||||||
|
level = String(parsed.level || parsed['log.level'] || (parsed.log as Record<string, unknown>)?.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');
|
||||||
|
}
|
||||||
Reference in new issue
Block a user