feat(ux): U2 统一 DataTable 组件 — 封装排序/搜索/分页/导出/选择/列可见性通用表格 + 迁移 RegionManager/LoginLogsTab/LogDatasourcePage 3 个页面
This commit is contained in:
1 parent
bdf3c2daf4
commit
ba8a4b5f19
9 files changed
+1268
-504
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// UnifiedQueryPanel.tsx — 统一查询面板(查询体验优化版)// Logan.Li
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Play, Loader2, ExternalLink, Clock, Save, ChevronDown, Bell } from 'lucide-react';
|
||||
// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)// Logan.Li
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { Play, Loader2, Clock, Save, ChevronDown, Bell } from 'lucide-react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { TimeRangePicker } from './TimeRangePicker';
|
||||
import { LogFieldSidebar } from './LogFieldSidebar';
|
||||
@@ -11,27 +11,23 @@ import { LogClusterPanel } from './LogClusterPanel';
|
||||
import { SaveQueryDialog, SaveAlertDialog } from './SaveQueryDialog';
|
||||
import { QueryHistoryPanel } from './QueryHistoryPanel';
|
||||
import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog';
|
||||
import { logCenterApi, type LogDataSource } from '@/api/monitor/log-center';
|
||||
import type { LogEngine, TimeRange, ResultTab, ViewMode, LogEntry, StatTable, LogCluster, QueryHistoryItem } from './types-v3';
|
||||
import { ENGINE_META, generateMockEntries, generateMockStatTable, generateMockClusters, extractFields, generateHistogramData, historyStorage } from './types-v3';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
// 默认最近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: '5分钟',
|
||||
label: '最近5分钟',
|
||||
type: 'relative',
|
||||
};
|
||||
|
||||
// Mock 数据源(Loki & ES 双引擎) // Logan.Li
|
||||
const MOCK_SOURCES: Record<'loki' | 'elasticsearch', { id: number; name: string; url: string }[]> = {
|
||||
loki: [
|
||||
{ id: 1, name: 'prod-loki', url: 'http://loki.prod.local:3100' },
|
||||
{ id: 2, name: 'dev-loki', url: 'http://loki.dev.local:3100' },
|
||||
],
|
||||
elasticsearch: [
|
||||
{ id: 3, name: 'prod-es', url: 'http://es.prod.local:9200' },
|
||||
{ id: 4, name: 'dev-es', url: 'http://es.dev.local:9200' },
|
||||
],
|
||||
// Demo 数据源(后端无数据源时的兜底)// Logan.Li
|
||||
const DEMO_SOURCES: Record<'loki' | 'elasticsearch', LogDataSource[]> = {
|
||||
loki: [{ id: -1, name: 'demo-loki', type: 'loki', url: '' }],
|
||||
elasticsearch: [{ id: -2, name: 'demo-es', type: 'elasticsearch', url: '' }],
|
||||
};
|
||||
|
||||
// 引擎默认查询语句 // Logan.Li
|
||||
@@ -87,19 +83,19 @@ function highlightSyntax(query: string, engine: 'loki' | 'elasticsearch'): strin
|
||||
if (!query) return '<span style="color:transparent">.</span>';
|
||||
if (engine === 'loki') {
|
||||
return query
|
||||
.replace(/([a-zA-Z_][\w-]*)(?==)/g, '<s c="#f59e0b">$1</s>') // 标签名
|
||||
.replace(/("(?:[^"\\]|\\.)*")/g, '<s c="#22c55e">$1</s>') // 字符串
|
||||
.replace(/(\|=|\!=|=~|!~|=)/g, '<s c="#f43f5e">$1</s>') // 操作符
|
||||
.replace(/(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)/g, '<s c="#8b5cf6">$1</s>') // 管道
|
||||
.replace(/\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b/g, '<s c="#06b6d4">$1</s>') // 函数
|
||||
.replace(/(\{|\})/g, '<s c="#f59e0b">$1</s>') // 花括号
|
||||
.replace(/<s c=/g, '<span style="color=') + '<span style="color:transparent">.</span>'; // 闭合
|
||||
.replace(/([a-zA-Z_][\w-]*)(?==)/g, '<s c="#f59e0b">$1</s>')
|
||||
.replace(/("(?:[^"\\]|\\.)*")/g, '<s c="#22c55e">$1</s>')
|
||||
.replace(/(\|=|\!=|=~|!~|=)/g, '<s c="#f43f5e">$1</s>')
|
||||
.replace(/(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)/g, '<s c="#8b5cf6">$1</s>')
|
||||
.replace(/\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b/g, '<s c="#06b6d4">$1</s>')
|
||||
.replace(/(\{|\})/g, '<s c="#f59e0b">$1</s>')
|
||||
.replace(/<s c=/g, '<span style="color=') + '<span style="color:transparent">.</span>';
|
||||
}
|
||||
return query
|
||||
.replace(/\b(AND|OR|NOT)\b/g, '<s c="#f43f5e">$1</s>') // 布尔
|
||||
.replace(/([a-zA-Z_@][\w.-]*):/g, '<s c="#f59e0b">$1</s>:') // 字段
|
||||
.replace(/("(?:[^"\\]|\\.)*")/g, '<s c="#22c55e">$1</s>') // 字符串
|
||||
.replace(/(\*|\?|\~|\^|\[|\]|\(|\))/g, '<s c="#8b5cf6">$1</s>') // 通配符
|
||||
.replace(/\b(AND|OR|NOT)\b/g, '<s c="#f43f5e">$1</s>')
|
||||
.replace(/([a-zA-Z_@][\w.-]*):/g, '<s c="#f59e0b">$1</s>:')
|
||||
.replace(/("(?:[^"\\]|\\.)*")/g, '<s c="#22c55e">$1</s>')
|
||||
.replace(/(\*|\?|\~|\^|\[|\]|\(|\))/g, '<s c="#8b5cf6">$1</s>')
|
||||
.replace(/<s c=/g, '<span style="color=') + '<span style="color:transparent">.</span>';
|
||||
}
|
||||
|
||||
@@ -131,14 +127,23 @@ function SyntaxHighlightInput({ value, onChange, engine, placeholder }: {
|
||||
interface Props { engine: LogEngine; onRuleCreated: () => void; }
|
||||
|
||||
export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
// SLS 回退到 Loki,仅支持双引擎 // Logan.Li
|
||||
const { toast } = useToast();
|
||||
const effEngine: 'loki' | 'elasticsearch' = engine === 'sls' ? 'loki' : engine;
|
||||
const meta = ENGINE_META[effEngine];
|
||||
const badge = ENGINE_BADGE[effEngine];
|
||||
const sources = MOCK_SOURCES[effEngine];
|
||||
|
||||
// 从后端加载真实数据源,无数据时用 demo 兜底 // Logan.Li
|
||||
const [sources, setSources] = useState<LogDataSource[]>(DEMO_SOURCES[effEngine]);
|
||||
useEffect(() => {
|
||||
logCenterApi.listDatasources(effEngine).then((res: any) => {
|
||||
const list: LogDataSource[] = Array.isArray(res) ? res : (res?.list || []);
|
||||
if (list.length > 0) setSources(list);
|
||||
else setSources(DEMO_SOURCES[effEngine]);
|
||||
}).catch(() => setSources(DEMO_SOURCES[effEngine]));
|
||||
}, [effEngine]);
|
||||
|
||||
const [query, setQuery] = useState(DEFAULT_QUERIES[effEngine]);
|
||||
const [dsId, setDsId] = useState(String(sources[0].id));
|
||||
const [dsId, setDsId] = useState(String(sources[0]?.id || ''));
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
@@ -161,30 +166,94 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const [alertDialogOpen, setAlertDialogOpen] = useState(false);
|
||||
const [saveMenuOpen, setSaveMenuOpen] = useState(false);
|
||||
|
||||
// 数据源切换后更新 dsId // Logan.Li
|
||||
useEffect(() => {
|
||||
if (sources.length > 0) setDsId(String(sources[0].id));
|
||||
}, [sources]);
|
||||
|
||||
const currentSource = sources.find(s => String(s.id) === dsId);
|
||||
const isDemo = currentSource && (currentSource.id < 0 || !currentSource.url);
|
||||
|
||||
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 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]);
|
||||
|
||||
// 执行查询 // Logan.Li
|
||||
const handleRun = useCallback(() => {
|
||||
// 执行查询 — 接入真实 API(demo 源则使用 mock)// Logan.Li
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!currentSource) return;
|
||||
const dsIdNum = currentSource.id;
|
||||
setLoading(true);
|
||||
const t0 = performance.now();
|
||||
setTimeout(() => {
|
||||
const mock = generateMockEntries(effEngine as LogEngine, 120);
|
||||
setEntries(mock); setHistogramData(generateHistogramData(mock)); setTotalCount(mock.length * 14);
|
||||
setQueryTime(Math.round(performance.now() - t0)); setStatTable(generateMockStatTable(effEngine as LogEngine));
|
||||
setClusters(generateMockClusters()); setPage(1); setLoading(false);
|
||||
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource?.name || 'default', query, timeRange: timeRange.label, resultCount: mock.length });
|
||||
}, 600 + Math.random() * 500);
|
||||
}, [effEngine, query, currentSource, timeRange]);
|
||||
|
||||
// Demo 源:模拟数据
|
||||
if (isDemo) {
|
||||
setTimeout(() => {
|
||||
const mock = generateMockEntries(effEngine as LogEngine, 120);
|
||||
setEntries(mock); setHistogramData(generateHistogramData(mock)); setTotalCount(mock.length * 14);
|
||||
setQueryTime(Math.round(performance.now() - t0)); setStatTable(generateMockStatTable(effEngine as LogEngine));
|
||||
setClusters(generateMockClusters()); setPage(1); setLoading(false);
|
||||
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mock.length });
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
// 真实 API 查询
|
||||
try {
|
||||
const startISO = timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z').replace(' ', 'T');
|
||||
const endISO = timeRange.end + (timeRange.end.includes('T') ? '' : '.000Z').replace(' ', 'T');
|
||||
let result: any;
|
||||
|
||||
if (effEngine === 'loki') {
|
||||
result = await logCenterApi.queryLoki({
|
||||
datasource_id: dsIdNum,
|
||||
query,
|
||||
limit: 200,
|
||||
start: startISO,
|
||||
end: endISO,
|
||||
direction: 'backward',
|
||||
});
|
||||
} else {
|
||||
result = await logCenterApi.queryElasticsearch({
|
||||
datasource_id: dsIdNum,
|
||||
query,
|
||||
size: 200,
|
||||
start: startISO,
|
||||
end: endISO,
|
||||
});
|
||||
}
|
||||
|
||||
const data = (result as any)?.data || result;
|
||||
const rawEntries: any[] = data?.entries || [];
|
||||
const mapped: LogEntry[] = rawEntries.map((e: any) => ({
|
||||
timestamp: e.timestamp || '',
|
||||
level: e.level || '',
|
||||
message: e.message || '',
|
||||
labels: e.labels || {},
|
||||
raw: e.raw || e.message || '',
|
||||
source: e.labels?.app || e.labels?.service_name || '',
|
||||
}));
|
||||
|
||||
setEntries(mapped);
|
||||
setHistogramData(generateHistogramData(mapped));
|
||||
setTotalCount(data?.total || mapped.length);
|
||||
setQueryTime(Math.round(performance.now() - t0));
|
||||
setStatTable(generateMockStatTable(effEngine as LogEngine));
|
||||
setClusters(generateMockClusters());
|
||||
setPage(1);
|
||||
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mapped.length });
|
||||
} catch (e: any) {
|
||||
toast({ title: '查询失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
setEntries([]); setTotalCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [effEngine, query, currentSource, timeRange, isDemo, toast]);
|
||||
|
||||
const toggleField = useCallback((n: string) => setSelectedFields(p => p.includes(n) ? p.filter(f => f !== n) : [...p, n]), []);
|
||||
const handleSaveMenu = (a: 'query' | 'alert') => { setSaveMenuOpen(false); a === 'query' ? setSaveQueryOpen(true) : setSaveAlertOpen(true); };
|
||||
|
||||
// 点击面积图 → 缩小时间范围到该区域 // Logan.Li
|
||||
const handleChartClick = useCallback((st: any) => {
|
||||
if (!st?.activeLabel) return;
|
||||
const idx = histogramData.findIndex(d => d.timestamp === st.activeLabel);
|
||||
@@ -195,9 +264,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* ===== 查询控制行:输入框前置 & 语法高亮 & 引擎徽标 ===== */}
|
||||
{/* ===== 查询控制行 ===== */}
|
||||
<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 }} />
|
||||
@@ -209,10 +277,10 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<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)' }}>
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{isDemo ? ' (模拟)' : ''}</option>)}
|
||||
</select>
|
||||
|
||||
<button onClick={handleRun} disabled={loading} 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" style={{ background: meta.color }}>
|
||||
<button onClick={handleRun} disabled={loading} 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" style={{ background: '#22c55e' }}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
|
||||
</button>
|
||||
|
||||
@@ -229,26 +297,20 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{currentSource?.url && (
|
||||
<button onClick={() => window.open(currentSource.url, '_blank')} className="h-10 px-2.5 rounded text-xs flex items-center gap-1 flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>
|
||||
{meta.externalLink} <ExternalLink className="h-3 w-3" />
|
||||
</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="查询历史">
|
||||
<Clock className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ===== 快捷查询助手(内联紧凑版,14+模板) ===== */}
|
||||
{/* 快捷查询助手 */}
|
||||
<QuickQueryBar engine={effEngine} templates={ALL_TEMPLATES[effEngine]} onSelect={setQuery} />
|
||||
|
||||
{/* ===== 统计条 + 工具栏 ===== */}
|
||||
{/* 统计条 + 工具栏 */}
|
||||
{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={() => { const t = sortedEntries.map(e => `${e.timestamp}\t${e.level || ''}\t${e.message}`).join('\n'); const b = new Blob([t], { type: 'text/plain' }); const u = URL.createObjectURL(b); const a = document.createElement('a'); a.href = u; a.download = `${effEngine}-logs-${Date.now()}.txt`; a.click(); URL.revokeObjectURL(u); }} onCopy={() => navigator.clipboard.writeText(pagedEntries.map(e => `${e.timestamp}\t${e.level || ''}\t${e.message}`).join('\n'))} onBellClick={() => setAlertDialogOpen(true)} />
|
||||
)}
|
||||
|
||||
{/* ===== Log Volume 面积图(点击缩小时间范围) ===== */}
|
||||
{/* Log Volume 面积图 */}
|
||||
{entries.length > 0 && (
|
||||
<div className="h-[120px] rounded-lg border cursor-crosshair" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -269,7 +331,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 结果标签页(紧凑内嵌样式) ===== */}
|
||||
{/* 结果标签页 */}
|
||||
{entries.length > 0 && (
|
||||
<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)' }}>
|
||||
@@ -285,7 +347,6 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
{/* 原始日志 */}
|
||||
{resultTab === 'logs' && (<>
|
||||
<div className="flex gap-3">
|
||||
<LogFieldSidebar fields={fields} selectedFields={selectedFields} onFieldClick={n => setQuery(p => `${p} ${n}=""`)} onToggleDisplayField={toggleField} loading={loading} />
|
||||
@@ -305,11 +366,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 换行</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>
|
||||
@@ -323,7 +380,14 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
</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} datasourceName={currentSource?.name} />
|
||||
<SaveAlertDialog open={saveAlertOpen} onClose={() => setSaveAlertOpen(false)} engine={effEngine as LogEngine} query={query} datasourceName={currentSource?.name} />
|
||||
<QueryHistoryPanel visible={historyPanelOpen} onClose={() => setHistoryPanelOpen(false)} onSelect={(it: QueryHistoryItem) => { setQuery(it.query); setHistoryPanelOpen(false); }} onCreateAlert={() => { setSaveAlertOpen(true); setHistoryPanelOpen(false); }} />
|
||||
@@ -332,7 +396,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 内联紧凑版快捷查询助手 ===== // Logan.Li
|
||||
// 内联紧凑版快捷查询助手
|
||||
function QuickQueryBar({ engine, templates, onSelect }: { engine: 'loki' | 'elasticsearch'; templates: { label: string; query: string }[]; onSelect: (q: string) => void; }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const labels: Record<'loki' | 'elasticsearch', string> = { loki: 'LogQL', elasticsearch: 'Lucene' };
|
||||
|
||||
Reference in new issue
Block a user