Files
aiops/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx
T

446 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}