446 lines
29 KiB
TypeScript
446 lines
29 KiB
TypeScript
// 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';
|
||
import { EnhancedLogResults, extractCommonLabels } from './EnhancedLogResults';
|
||
import { LogQueryToolbar } from './LogQueryToolbar';
|
||
import { StatChartPanel } from './StatChartPanel';
|
||
import { LogClusterPanel } from './LogClusterPanel';
|
||
import { SaveQueryDialog, SaveAlertDialog } from './SaveQueryDialog';
|
||
import { QueryHistoryPanel } from './QueryHistoryPanel';
|
||
import { 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分钟',
|
||
type: 'relative',
|
||
};
|
||
|
||
// 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
|
||
const DEFAULT_QUERIES: Record<'loki' | 'elasticsearch', string> = {
|
||
loki: '{app="cex-admin-api"} |= ""',
|
||
elasticsearch: 'app:cex-admin-api',
|
||
};
|
||
|
||
// 引擎徽标:左侧小圆点 + 语言标签 // Logan.Li
|
||
const ENGINE_BADGE: Record<'loki' | 'elasticsearch', { label: string; dotColor: string }> = {
|
||
loki: { label: 'LogQL', dotColor: '#06b6d4' },
|
||
elasticsearch: { label: 'Lucene', dotColor: '#22c55e' },
|
||
};
|
||
|
||
// 增强快捷查询模板(14+ 条/引擎) // Logan.Li
|
||
const ALL_TEMPLATES: Record<'loki' | 'elasticsearch', { label: string; query: string }[]> = {
|
||
loki: [
|
||
{ label: 'Nginx 错误', query: '{job="nginx"} |= "error"' },
|
||
{ label: 'JSON 过滤', query: '{namespace="prod"} | json | level="warn"' },
|
||
{ label: '超时搜索', query: '{app="api"} |= "timeout"' },
|
||
{ label: '级别统计', query: 'sum by(level) (count_over_time({job="app"}[5m]))' },
|
||
{ label: '正则多级别', query: '{container="frontend"} |~ "ERROR|WARN|FATAL"' },
|
||
{ label: '慢请求>1s', query: '{job="api"} | json | response_time > 1000' },
|
||
{ label: '错误率统计', query: 'sum by(level) (count_over_time({app="cex-admin-api"} |= "error" [5m]))' },
|
||
{ label: '追踪ID查询', query: '{app="cex-admin-api"} |= "trace_id" | json' },
|
||
{ label: '服务聚合', query: 'sum by(service_name) (count_over_time({namespace="astralx"}[5m]))' },
|
||
{ label: '高延迟筛选', query: '{app="cex-admin-api"} | json | response_time > 500' },
|
||
{ label: '异常堆栈', query: '{app="cex-admin-api"} |= "Exception"' },
|
||
{ label: '日志趋势', query: 'sum(count_over_time({app="cex-admin-api"}[1h]))' },
|
||
{ label: '正则提取', query: '{app="cex-admin-api"} | regexp "(?P<method>\\w+) (?P<path>\\S+)"' },
|
||
{ label: '格式输出', query: '{app="cex-admin-api"} | json | line_format "{{.level}} {{.message}}"' },
|
||
],
|
||
elasticsearch: [
|
||
{ label: 'API 5xx', query: 'status:500 AND path:"/api/*"' },
|
||
{ label: '错误超时', query: 'level:error AND message:*timeout*' },
|
||
{ label: '排除调试', query: 'host.name:server-* AND NOT level:debug' },
|
||
{ label: '慢请求', query: 'response_time:>1000 AND method:POST' },
|
||
{ label: '严重错误', query: '@timestamp:[now-1h TO now] AND level:critical' },
|
||
{ label: '服务错误', query: 'service:nginx AND (level:error OR level:fatal)' },
|
||
{ label: '组合查询', query: 'app:cex-admin-api AND (level:error OR level:warn)' },
|
||
{ label: '模糊匹配', query: 'message:*timeout* AND app:cex-admin-api' },
|
||
{ label: '范围查询', query: 'response_time:[1000 TO *] AND method:POST' },
|
||
{ label: '排除路径', query: 'app:cex-admin-api AND NOT path:/health' },
|
||
{ label: '时间过滤', query: '@timestamp:[now-1h TO now] AND level:error' },
|
||
{ label: 'IP段过滤', query: 'client_ip:172.36.* AND app:cex-admin-api' },
|
||
{ label: '聚合统计', query: 'app:cex-admin-api | STATS count(*) by level' },
|
||
{ label: '嵌套字段', query: 'labels.orgId:10001 AND app:cex-admin-api' },
|
||
],
|
||
};
|
||
|
||
// LogQL / Lucene 语法高亮 → HTML // Logan.Li
|
||
function highlightSyntax(query: string, engine: 'loki' | 'elasticsearch'): string {
|
||
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>';
|
||
}
|
||
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(/<s c=/g, '<span style="color=') + '<span style="color:transparent">.</span>';
|
||
}
|
||
|
||
// 语法高亮输入框:底层高亮 + 上层透明输入 // Logan.Li
|
||
function SyntaxHighlightInput({ value, onChange, engine, placeholder }: {
|
||
value: string; onChange: (v: string) => void; engine: 'loki' | 'elasticsearch'; placeholder?: string;
|
||
}) {
|
||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||
const preRef = useRef<HTMLPreElement>(null);
|
||
const sync = useCallback(() => {
|
||
if (preRef.current && taRef.current) { preRef.current.scrollLeft = taRef.current.scrollLeft; preRef.current.scrollTop = taRef.current.scrollTop; }
|
||
}, []);
|
||
const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key === 'Tab') { e.preventDefault(); const t = e.currentTarget, s = t.selectionStart, ed = t.selectionEnd; onChange(value.slice(0, s) + ' ' + value.slice(ed)); requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 2; }); }
|
||
};
|
||
const html = useMemo(() => highlightSyntax(value, engine), [value, engine]);
|
||
return (
|
||
<div className="relative flex-1 min-w-[120px]" style={{ height: '40px' }}>
|
||
<pre ref={preRef} className="absolute inset-0 m-0 font-mono text-xs rounded pointer-events-none overflow-hidden whitespace-pre-wrap break-all px-3 py-2.5" style={{ background: 'transparent', border: '1px solid transparent', lineHeight: '20px', color: 'var(--dg-text-primary)', zIndex: 1 }} dangerouslySetInnerHTML={{ __html: html }} />
|
||
<textarea ref={taRef} value={value} onChange={e => onChange(e.target.value)} onScroll={sync} onKeyDown={onKey} spellCheck={false} autoComplete="off" rows={1}
|
||
className="absolute inset-0 w-full h-full font-mono text-xs rounded resize-none outline-none overflow-auto whitespace-pre-wrap break-all px-3 py-2.5"
|
||
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'transparent', caretColor: 'var(--dg-text-primary)', lineHeight: '20px', zIndex: 2 }}
|
||
placeholder={placeholder} />
|
||
{!value && <div className="absolute inset-0 flex items-center px-3 pointer-events-none" style={{ zIndex: 1 }}><span className="text-xs font-mono" style={{ color: 'var(--dg-text-muted)' }}>{placeholder}</span></div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface Props { engine: LogEngine; onRuleCreated: () => void; }
|
||
|
||
export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||
const { toast } = useToast();
|
||
const effEngine: 'loki' | 'elasticsearch' = engine === 'sls' ? 'loki' : engine;
|
||
const meta = ENGINE_META[effEngine];
|
||
const badge = ENGINE_BADGE[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 || []);
|
||
// 合并真实数据源 + demo 兜底(真实源在前) // Logan.Li
|
||
if (list.length > 0) setSources([...list, ...DEMO_SOURCES[effEngine]]);
|
||
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 [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
|
||
const [loading, setLoading] = useState(false);
|
||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||
const [statTable, setStatTable] = useState<StatTable | undefined>();
|
||
const [clusters, setClusters] = useState<LogCluster[]>([]);
|
||
const [histogramData, setHistogramData] = useState<{ timestamp: string; count: number }[]>([]);
|
||
const [totalCount, setTotalCount] = useState(0);
|
||
const [queryTime, setQueryTime] = useState(0);
|
||
const [resultTab, setResultTab] = useState<ResultTab>('logs');
|
||
const [viewMode, setViewMode] = useState<ViewMode>('raw');
|
||
const [wrapLines, setWrapLines] = useState(false);
|
||
const [truncate, setTruncate] = useState(true);
|
||
const [selectedFields, setSelectedFields] = useState<string[]>([]);
|
||
const [page, setPage] = useState(1);
|
||
const [perPage, setPerPage] = useState(20);
|
||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||
const [saveQueryOpen, setSaveQueryOpen] = useState(false);
|
||
const [saveAlertOpen, setSaveAlertOpen] = useState(false);
|
||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||
const [alertDialogOpen, setAlertDialogOpen] = useState(false);
|
||
const [saveMenuOpen, setSaveMenuOpen] = useState(false);
|
||
const [hasQueried, setHasQueried] = 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]);
|
||
|
||
// 执行查询 — 接入真实 API(demo 源则使用 mock)// Logan.Li
|
||
const handleRun = useCallback(async () => {
|
||
if (!currentSource) return;
|
||
const dsIdNum = currentSource.id;
|
||
setLoading(true);
|
||
const t0 = performance.now();
|
||
|
||
// 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); setHasQueried(true);
|
||
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);
|
||
setHasQueried(true);
|
||
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); setHasQueried(true);
|
||
} 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); };
|
||
|
||
const handleChartClick = useCallback((st: any) => {
|
||
if (!st?.activeLabel) return;
|
||
const idx = histogramData.findIndex(d => d.timestamp === st.activeLabel);
|
||
if (idx < 0) return;
|
||
const sIdx = Math.max(0, idx - 1), eIdx = Math.min(histogramData.length - 1, idx + 1);
|
||
setTimeRange(p => ({ ...p, start: histogramData[sIdx].timestamp, end: histogramData[eIdx].timestamp, label: '自定义', type: 'absolute' as const }));
|
||
}, [histogramData]);
|
||
|
||
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 }} />
|
||
<span className="text-[11px] font-semibold" style={{ color: badge.dotColor }}>{badge.label}</span>
|
||
</div>
|
||
<SyntaxHighlightInput value={query} onChange={setQuery} engine={effEngine} placeholder={meta.queryPlaceholder} />
|
||
</div>
|
||
|
||
<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}{(s.id < 0 || !s.url) ? ' (模拟)' : ''}</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: '#22c55e' }}>
|
||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
|
||
</button>
|
||
|
||
<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" /> 另存为 <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" /> 另存为查询</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" /> 另存为告警</button>
|
||
</div>
|
||
</>)}
|
||
</div>
|
||
|
||
<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>
|
||
|
||
{/* 快捷查询助手 */}
|
||
<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 面积图 */}
|
||
{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%">
|
||
<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={(v: string) => v.slice(-5)} interval="preserveStartEnd" />
|
||
<YAxis tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={false} tickLine={false} width={30} />
|
||
<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} 条日志`, '数量']} labelFormatter={(l: string) => `时间: ${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 && (
|
||
<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 => {
|
||
const labels: Record<ResultTab, string> = { logs: '原始日志', stats: '统计图表', cluster: '日志聚类' };
|
||
return (
|
||
<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 ? 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' }}>
|
||
{labels[tab]}
|
||
</button>
|
||
);
|
||
})}
|
||
</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} />
|
||
<div className="flex-1 min-w-0">
|
||
<EnhancedLogResults entries={pagedEntries} loading={loading} viewMode={viewMode} wrapLines={wrapLines} truncate={truncate} commonLabels={commonLabels || undefined} />
|
||
</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 ? meta.color : 'var(--dg-bg-tertiary)', color: viewMode === m ? '#fff' : 'var(--dg-text-muted)' }}>
|
||
{m === 'raw' ? '原始日志' : m === 'field' ? '字段视图' : '表格视图'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 截断</label>
|
||
<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>
|
||
)}
|
||
|
||
{/* 空状态:从未查询 vs 查询返回 0 结果 */}
|
||
{!loading && entries.length === 0 && !hasQueried && (
|
||
<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)' }}>输入查询语句并点击「查询」查看结果</span>
|
||
<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>
|
||
</div>
|
||
)}
|
||
{!loading && entries.length === 0 && hasQueried && (
|
||
<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)' }}>未找到匹配的日志记录</p>
|
||
<p className="text-xs mt-1" style={{ color: 'var(--dg-text-muted)' }}>
|
||
查询已执行({queryTime}ms),但当前时间范围和筛选条件下无匹配结果
|
||
</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" />重新查询
|
||
</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} 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); }} />
|
||
<LogAlertFromQueryDialog open={alertDialogOpen} onOpenChange={setAlertDialogOpen} ctx={{ engine: effEngine as LogEngine, datasourceId: currentSource?.id, datasourceName: currentSource?.name, query, timeRange: timeRange.label }} onSaved={onRuleCreated} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 内联紧凑版快捷查询助手
|
||
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' };
|
||
return (
|
||
<div>
|
||
<button onClick={() => setOpen(!open)} className="flex items-center gap-1 text-[11px] px-2 py-1 rounded transition-colors" style={{ color: 'var(--dg-blue)', background: open ? 'var(--dg-bg-tertiary)' : 'transparent' }}>
|
||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--dg-blue)' }} />
|
||
{labels[engine]} 快捷查询模板
|
||
<ChevronDown className="h-3 w-3" style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 0.2s' }} />
|
||
</button>
|
||
{open && (
|
||
<div className="mt-1.5 p-2.5 rounded-lg border" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||
<div className="grid grid-cols-3 lg:grid-cols-4 gap-1.5">
|
||
{templates.map((t, i) => (
|
||
<button key={i} onClick={() => { onSelect(t.query); setOpen(false); }} className="text-left px-2 py-1.5 rounded border text-[11px] transition-all hover:opacity-80" style={{ background: 'var(--dg-bg-tertiary)', borderColor: 'var(--dg-border)', color: 'var(--dg-text-secondary)' }} title={t.query}>
|
||
<span className="font-medium block truncate" style={{ color: 'var(--dg-text-primary)' }}>{t.label}</span>
|
||
<span className="block truncate opacity-60 font-mono mt-0.5 text-[10px]">{t.query}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|