feat(ux): U2 统一 DataTable 组件 — 封装排序/搜索/分页/导出/选择/列可见性通用表格 + 迁移 RegionManager/LoginLogsTab/LogDatasourcePage 3 个页面

This commit is contained in:
attacker committed 2026-06-27 12:02:34 +08:00
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' };