// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)// Logan.Li import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; 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 { StatChartPanel } from './StatChartPanel'; import { LogClusterPanel } from './LogClusterPanel'; import { SaveQueryDialog, SaveAlertDialog } from './SaveQueryDialog'; import { QueryHistoryPanel } from './QueryHistoryPanel'; import { SavedQueriesPanel } from './SavedQueriesPanel'; import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog'; import { QueryAutocompleteInput } from './QueryAutocompleteInput'; import { QuickQueryBar } from './QuickQueryBar'; import { parseLogQueryUrl, buildLogQueryParams, buildShareUrl } from './query-url-state'; import { logCenterApi, type LogDataSource, type LogQueryEntry } from '@/api'; import { copyToClipboard } from '@/utils/clipboard'; import { buildLogCopyText, formatHistogramCount, formatHistogramTick, formatLogTimestamp } from './log-format'; import { EsIndexPicker } from './EsIndexPicker'; import { LokiLabelBrowser } from './LokiLabelBrowser'; import { applyESFieldFilter, applyLokiLabelFilter, extractLokiSelector } from './query-builder'; 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 { 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', }; // 引擎默认查询语句 // 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; } export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { const { toast } = useToast(); const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const urlBootstrapped = useRef(false); const autoRunDone = useRef(false); 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([]); useEffect(() => { logCenterApi.listDatasources(effEngine).then((res) => { const list: LogDataSource[] = res?.list || []; setSources(list); }).catch(() => setSources([])); }, [effEngine]); const [query, setQuery] = useState(DEFAULT_QUERIES[effEngine]); const [dsId, setDsId] = useState(String(sources[0]?.id || '')); // ES 查询允许在面板内临时覆盖索引(默认取自数据源配置)// Logan.Li const [esIndex, setEsIndex] = useState(''); // API 单次查询返回条数上限(后端限制 1000),Loki 用 limit / ES 用 size // Logan.Li const [queryLimit, setQueryLimit] = useState(1000); const [timeRange, setTimeRange] = useState(DEFAULT_TIME_RANGE); const [loading, setLoading] = useState(false); const [entries, setEntries] = useState([]); const [statTable, setStatTable] = useState(); const [clusters, setClusters] = useState([]); const [histogramData, setHistogramData] = useState<{ timestamp: string; count: number }[]>([]); const [totalCount, setTotalCount] = useState(0); const [queryTime, setQueryTime] = useState(0); const [resultTab, setResultTab] = useState('logs'); const [viewMode, setViewMode] = useState('raw'); const [wrapLines, setWrapLines] = useState(true); const [truncate, setTruncate] = useState(false); const [selectedFields, setSelectedFields] = useState([]); 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); const [hasMore, setHasMore] = useState(false); const [nextCursor, setNextCursor] = useState(''); const [searchAfter, setSearchAfter] = useState(); const [loadingMore, setLoadingMore] = useState(false); const [liveTail, setLiveTail] = useState(false); const [savedPanelOpen, setSavedPanelOpen] = useState(false); const [labelNames, setLabelNames] = useState([]); const [labelValues, setLabelValues] = useState([]); const [indexedFields, setIndexedFields] = useState([]); const timeIsoParams = useCallback(() => ({ start: timeRange.start.replace(' ', 'T'), end: timeRange.end.replace(' ', 'T'), }), [timeRange]); const currentSource = sources.find((s) => String(s.id) === dsId); // 数据源加载后选中默认或首个;首次从 URL 恢复查询状态 // Logan.Li useEffect(() => { if (sources.length === 0) return; if (!urlBootstrapped.current) { const parsed = parseLogQueryUrl(searchParams); const def = sources.find((s) => String(s.id) === parsed.datasourceId) || sources.find((s) => s.is_default) || sources[0]; setDsId(String(def.id)); setEsIndex(parsed.index || def.default_index || ''); if (parsed.query) setQuery(parsed.query); if (parsed.timeRange) setTimeRange(parsed.timeRange); urlBootstrapped.current = true; return; } const def = sources.find((s) => String(s.id) === dsId) || sources.find((s) => s.is_default) || sources[0]; if (!dsId && def) setDsId(String(def.id)); }, [sources, searchParams, dsId]); // 查询状态同步到 URL(可分享)// Logan.Li useEffect(() => { if (!urlBootstrapped.current) return; const p = buildLogQueryParams({ query, datasourceId: dsId || undefined, index: esIndex || undefined, timeRange, }); setSearchParams(p, { replace: true }); }, [query, dsId, esIndex, timeRange, setSearchParams]); // Loki 标签名(自动补全)// Logan.Li useEffect(() => { if (effEngine !== 'loki' || !currentSource?.id) return; logCenterApi.listLokiLabels(currentSource.id, timeIsoParams()) .then((res) => setLabelNames(Array.isArray(res) ? res : [])) .catch(() => setLabelNames([])); }, [effEngine, currentSource?.id, timeIsoParams]); // ES mapping 索引字段(Kibana 风格侧栏)// Logan.Li useEffect(() => { if (effEngine !== 'elasticsearch' || !currentSource?.id) return; logCenterApi.esMapping({ datasource_id: currentSource.id, index: esIndex || undefined }) .then((res) => { const list = Array.isArray(res) ? res : []; setIndexedFields(list.map((f) => ({ name: f.name, type: (['keyword', 'long', 'date', 'boolean', 'ip'].includes(f.type) ? f.type : 'text') as LogField['type'], }))); }) .catch(() => setIndexedFields([])); }, [effEngine, currentSource?.id, esIndex]); 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]); 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; level = String(parsed.level || parsed['log.level'] || (parsed.log as Record)?.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) => { try { if (effEngine === 'loki') { const res = await logCenterApi.lokiHistogram({ datasource_id: dsIdNum, query, start: startISO, end: endISO }); const buckets = res?.buckets || []; if (buckets.length > 0) { setHistogramData(buckets.map((b) => ({ timestamp: formatLogTimestamp(b.timestamp), count: b.count, }))); return; } } else { const res = await logCenterApi.esHistogram({ datasource_id: dsIdNum, query, index: esIndex || undefined, start: startISO, end: endISO, }); const buckets = res?.buckets || []; if (buckets.length > 0) { setHistogramData(buckets.map((b) => ({ timestamp: formatLogTimestamp(b.timestamp), count: b.count, }))); return; } } } catch { /* 直方图失败时回退客户端聚合 */ } }, [effEngine, query, esIndex]); // 执行查询 — 接入真实 API(无 mock)// Logan.Li const handleRun = useCallback(async () => { // 空查询兜底:后端 Query 字段 required,空值会 400,这里提前拦截 // Logan.Li if (!currentSource || !query.trim()) return; const dsIdNum = currentSource.id; setLoading(true); setHasMore(false); setNextCursor(''); setSearchAfter(undefined); const t0 = performance.now(); try { const startISO = (timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z')).replace(' ', 'T'); const endISO = (timeRange.end + (timeRange.end.includes('T') ? '' : '.000Z')).replace(' ', 'T'); const data = effEngine === 'loki' ? await logCenterApi.queryLoki({ datasource_id: dsIdNum, query, limit: queryLimit, start: startISO, end: endISO, direction: 'backward', }) : await logCenterApi.queryElasticsearch({ datasource_id: dsIdNum, index: esIndex || undefined, query, size: queryLimit, start: startISO, end: endISO, }); const rawEntries = data?.entries || []; const mapped = mapRawEntries(rawEntries); setEntries(mapped); void fetchHistogram(dsIdNum, startISO, endISO); setHistogramData(generateHistogramData(mapped)); setTotalCount(data?.total ?? mapped.length); setHasMore(!!data?.has_more); setNextCursor(data?.next_cursor || ''); setSearchAfter(data?.search_after); setQueryTime(Math.round(performance.now() - t0)); setStatTable(buildStatTableFromEntries(mapped)); setClusters(buildClustersFromEntries(mapped)); setPage(1); setHasQueried(true); historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mapped.length }); } catch (e: any) { const msg = e?.message || String(e); const title = msg.includes('401') || msg.includes('403') ? '认证失败' : msg.includes('404') ? '接口不存在' : msg.includes('无法连接') || msg.includes('timeout') ? '连接失败' : '查询失败'; toast({ title, description: msg, variant: 'destructive' }); setEntries([]); setTotalCount(0); setHasQueried(true); } finally { setLoading(false); } }, [effEngine, query, currentSource, timeRange, toast, queryLimit, esIndex, mapRawEntries, fetchHistogram]); const handleLoadMore = useCallback(async () => { if (!currentSource || !hasMore || loadingMore) return; setLoadingMore(true); try { const startISO = (timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z')).replace(' ', 'T'); const endISO = (timeRange.end + (timeRange.end.includes('T') ? '' : '.000Z')).replace(' ', 'T'); const dsIdNum = currentSource.id; const data = effEngine === 'loki' ? await logCenterApi.queryLoki({ datasource_id: dsIdNum, query, limit: queryLimit, start: startISO, end: endISO, direction: 'backward', cursor: nextCursor, }) : await logCenterApi.queryElasticsearch({ datasource_id: dsIdNum, index: esIndex || undefined, query, size: queryLimit, start: startISO, end: endISO, search_after: searchAfter, }); const more = mapRawEntries(data?.entries || []); setEntries((prev) => { const merged = [...prev, ...more]; setStatTable(buildStatTableFromEntries(merged)); setClusters(buildClustersFromEntries(merged)); return merged; }); setHasMore(!!data?.has_more); setNextCursor(data?.next_cursor || ''); setSearchAfter(data?.search_after); } catch (e: unknown) { toast({ title: '加载更多失败', description: (e as Error)?.message, variant: 'destructive' }); } finally { setLoadingMore(false); } }, [currentSource, hasMore, loadingMore, effEngine, query, queryLimit, timeRange, esIndex, nextCursor, searchAfter, mapRawEntries, toast]); const handleLabelFilter = useCallback((key: string, value: string, op: 'include' | 'exclude') => { if (effEngine === 'loki') { setQuery((q) => applyLokiLabelFilter(q, key, value, op)); } else { setQuery((q) => applyESFieldFilter(q, key, value, op)); } }, [effEngine]); const handleRequestLabelValues = useCallback((label: string) => { if (!currentSource?.id) return; const selector = extractLokiSelector(query); logCenterApi.listLokiLabelValues(currentSource.id, label, { ...timeIsoParams(), ...(selector ? { query: selector } : {}), }) .then((res) => setLabelValues(Array.isArray(res) ? res : [])) .catch(() => setLabelValues([])); }, [currentSource?.id, timeIsoParams, query]); const handleFieldClick = useCallback((field: string) => { if (effEngine === 'elasticsearch') { setQuery((q) => applyESFieldFilter(q, field, '*', 'include')); } else { setQuery((q) => `${q} ${field}=""`.trim()); } }, [effEngine]); const handleShareLink = useCallback(async () => { const url = buildShareUrl(location.pathname, { query, datasourceId: dsId, index: esIndex || undefined, timeRange, }, true); const full = `${window.location.origin}${url}`; const ok = await copyToClipboard(full); toast({ title: ok ? '分享链接已复制' : '复制失败', description: ok ? '收件人打开链接将自动执行查询' : undefined, variant: ok ? 'default' : 'destructive', }); }, [location.pathname, query, dsId, esIndex, timeRange, toast]); const runLiveTailTick = useCallback(async () => { if (!currentSource || !query.trim()) return; const now = new Date(); const start = new Date(now.getTime() - 5 * 60000); const tr: TimeRange = { start: start.toISOString().replace('T', ' ').substring(0, 19), end: now.toISOString().replace('T', ' ').substring(0, 19), label: 'Live', type: 'relative', }; setTimeRange(tr); const startISO = tr.start.replace(' ', 'T'); const endISO = tr.end.replace(' ', 'T'); try { const data = effEngine === 'loki' ? await logCenterApi.queryLoki({ datasource_id: currentSource.id, query, limit: 200, start: startISO, end: endISO, direction: 'backward', }) : await logCenterApi.queryElasticsearch({ datasource_id: currentSource.id, index: esIndex || undefined, query, size: 200, start: startISO, end: endISO, }); const mapped = mapRawEntries(data?.entries || []); setEntries((prev) => { const keys = new Set(prev.map((e) => `${e.timestamp}|${e.message}`)); const fresh = mapped.filter((e) => !keys.has(`${e.timestamp}|${e.message}`)); if (fresh.length === 0) return prev; const merged = [...fresh, ...prev].slice(0, 2000); setStatTable(buildStatTableFromEntries(merged)); setClusters(buildClustersFromEntries(merged)); setTotalCount(merged.length); setHasQueried(true); return merged; }); } catch { /* Live tail 静默失败 */ } }, [currentSource, query, effEngine, esIndex, mapRawEntries]); useEffect(() => { if (!liveTail) return undefined; void runLiveTailTick(); const id = setInterval(() => { void runLiveTailTick(); }, 3000); return () => clearInterval(id); }, [liveTail, runLiveTailTick]); const handleRunRef = useRef(handleRun); handleRunRef.current = handleRun; useEffect(() => { if (!urlBootstrapped.current || autoRunDone.current) return; if (searchParams.get('run') !== '1' || !currentSource || !query.trim()) return; autoRunDone.current = true; void handleRunRef.current(); }, [currentSource, query, searchParams]); const handleSavedQuerySelect = useCallback((item: SavedQuery) => { setQuery(item.query); if (String(item.datasourceId) !== dsId) setDsId(String(item.datasourceId)); }, [dsId]); const mappingFieldNames = useMemo(() => indexedFields.map((f) => f.name), [indexedFields]); 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]); const formatEntryLine = useCallback((e: LogEntry) => buildLogCopyText(e), []); const handleExportLogs = useCallback(() => { const t = sortedEntries.map(formatEntryLine).join('\n'); const b = new Blob([t], { type: 'text/plain;charset=utf-8' }); 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); toast({ title: '已导出', description: `${sortedEntries.length} 条日志` }); }, [sortedEntries, effEngine, formatEntryLine, toast]); const handleCopyLogs = useCallback(async () => { const text = pagedEntries.map(formatEntryLine).join('\n'); const ok = await copyToClipboard(text); if (ok) { toast({ title: '已复制', description: `当前页 ${pagedEntries.length} 条完整日志` }); } else { toast({ title: '复制失败', description: '请使用导出或展开单条复制', variant: 'destructive' }); } }, [pagedEntries, formatEntryLine, toast]); return (
{/* ===== 查询控制行 ===== */}
{badge.label}
{/* 次级控件行(时间 / 数据源 / 索引 / 上限 / 另存为 / 历史)// Logan.Li */}
{effEngine === 'elasticsearch' && ( )}
上限 { 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)' }} />
{saveMenuOpen && (<>
setSaveMenuOpen(false)} />
)}
{/* 无数据源时引导用户前往日志库配置 // Logan.Li */} {sources.length === 0 && (
暂无{meta.name}数据源,请先前往「日志库」添加数据源。
)} {/* 快捷查询助手 */} {effEngine === 'loki' && currentSource && ( )} {/* 统计条 + 工具栏 */} {entries.length > 0 && !loading && ( { 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 面积图 */} {entries.length > 0 && (
[`${v} 条日志`, '数量']} labelFormatter={(l: string) => `时间: ${l}`} />
)} {/* 结果标签页 */} {entries.length > 0 && (
{(['logs', 'stats', 'cluster'] as ResultTab[]).map(tab => { const labels: Record = { logs: '原始日志', stats: '统计图表', cluster: '日志聚类' }; return ( ); })}
{resultTab === 'logs' && (<>
{(['raw', 'field', 'table'] as const).map(m => ( ))}
)} {resultTab === 'stats' && setSaveAlertOpen(true)} onSaveAsQuery={() => setSaveQueryOpen(true)} />} {resultTab === 'cluster' && }
)} {/* 空状态:从未查询 vs 查询返回 0 结果 */} {!loading && entries.length === 0 && !hasQueried && (
输入查询语句并点击「查询」查看结果 {meta.queryPlaceholder}
)} {!loading && entries.length === 0 && hasQueried && (

未找到匹配的日志记录

查询已执行({queryTime}ms),但当前时间范围和筛选条件下无匹配结果

)} {/* Loading */} {loading && entries.length === 0 && (
)} {/* 对话框 */} setSaveQueryOpen(false)} engine={effEngine as LogEngine} query={query} timeRange={timeRange} datasourceId={currentSource?.id} datasourceName={currentSource?.name} /> setSavedPanelOpen(false)} onSelect={handleSavedQuerySelect} /> setSaveAlertOpen(false)} engine={effEngine as LogEngine} query={query} datasourceId={currentSource?.id} datasourceName={currentSource?.name} /> setHistoryPanelOpen(false)} onSelect={(it: QueryHistoryItem) => { setQuery(it.query); setHistoryPanelOpen(false); }} onCreateAlert={() => { setSaveAlertOpen(true); setHistoryPanelOpen(false); }} />
); }