- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖 - 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败 - Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS - 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率 - 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示 - 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
667 lines
36 KiB
TypeScript
667 lines
36 KiB
TypeScript
// 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<LogDataSource[]>([]);
|
||
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<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(true);
|
||
const [truncate, setTruncate] = useState(false);
|
||
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);
|
||
const [hasMore, setHasMore] = useState(false);
|
||
const [nextCursor, setNextCursor] = useState('');
|
||
const [searchAfter, setSearchAfter] = useState<unknown[] | undefined>();
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
const [liveTail, setLiveTail] = useState(false);
|
||
const [savedPanelOpen, setSavedPanelOpen] = useState(false);
|
||
const [labelNames, setLabelNames] = useState<string[]>([]);
|
||
const [labelValues, setLabelValues] = useState<string[]>([]);
|
||
const [indexedFields, setIndexedFields] = useState<LogField[]>([]);
|
||
|
||
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<string, unknown>;
|
||
level = String(parsed.level || parsed['log.level'] || (parsed.log as Record<string, unknown>)?.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 (
|
||
<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>
|
||
<QueryAutocompleteInput
|
||
value={query}
|
||
onChange={setQuery}
|
||
engine={effEngine}
|
||
placeholder={meta.queryPlaceholder}
|
||
labelNames={labelNames}
|
||
labelValues={labelValues}
|
||
mappingFields={mappingFieldNames}
|
||
onRequestLabelValues={handleRequestLabelValues}
|
||
/>
|
||
<button onClick={() => { setLiveTail(false); void handleRun(); }} disabled={loading || liveTail || !currentSource || !query.trim()} 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 disabled:opacity-40 disabled:cursor-not-allowed" style={{ background: '#22c55e' }}>
|
||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
|
||
</button>
|
||
<button
|
||
onClick={() => setLiveTail((v) => !v)}
|
||
disabled={!currentSource || !query.trim()}
|
||
className="h-10 px-3 rounded text-xs flex items-center gap-1 flex-shrink-0"
|
||
style={{
|
||
border: `1px solid ${liveTail ? '#ef4444' : 'var(--dg-border)'}`,
|
||
color: liveTail ? '#ef4444' : 'var(--dg-text-secondary)',
|
||
background: liveTail ? 'rgba(239,68,68,0.1)' : 'var(--dg-bg-tertiary)',
|
||
}}
|
||
title="Live Tail — 每 3 秒刷新最近 5 分钟日志"
|
||
>
|
||
<Radio className={`h-3.5 w-3.5 ${liveTail ? 'animate-pulse' : ''}`} /> Tail
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/* 次级控件行(时间 / 数据源 / 索引 / 上限 / 另存为 / 历史)// Logan.Li */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<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.length === 0 && <option value="">无可用{meta.name}数据源</option>}
|
||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? ' (默认)' : ''}</option>)}
|
||
</select>
|
||
|
||
{effEngine === 'elasticsearch' && (
|
||
<EsIndexPicker
|
||
datasourceId={currentSource?.id}
|
||
value={esIndex}
|
||
onChange={setEsIndex}
|
||
/>
|
||
)}
|
||
|
||
<div className="flex items-center gap-1.5 h-10 px-2 rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)' }}>
|
||
<span className="text-xs text-[var(--dg-text-muted)]">上限</span>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={1000}
|
||
value={queryLimit}
|
||
onChange={e => {
|
||
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)' }}
|
||
/>
|
||
</div>
|
||
|
||
<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={() => { void handleShareLink(); }} className="h-10 px-2.5 flex items-center gap-1 rounded flex-shrink-0 text-xs" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="复制分享链接">
|
||
<Link2 className="h-3.5 w-3.5" /> 分享
|
||
</button>
|
||
<button onClick={() => setSavedPanelOpen(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="已保存查询">
|
||
<Bookmark className="h-4 w-4" />
|
||
</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>
|
||
|
||
{/* 无数据源时引导用户前往日志库配置 // Logan.Li */}
|
||
{sources.length === 0 && (
|
||
<div className="p-3 rounded border text-xs" style={{ background: 'rgba(245,158,11,0.1)', borderColor: 'rgba(245,158,11,0.3)', color: '#f59e0b' }}>
|
||
暂无{meta.name}数据源,请先前往「日志库」添加数据源。
|
||
</div>
|
||
)}
|
||
|
||
{/* 快捷查询助手 */}
|
||
<QuickQueryBar engine={effEngine} onSelect={setQuery} />
|
||
|
||
{effEngine === 'loki' && currentSource && (
|
||
<LokiLabelBrowser
|
||
datasourceId={currentSource.id}
|
||
query={query}
|
||
timeRange={timeRange}
|
||
onQueryChange={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={handleExportLogs} onCopy={() => { void handleCopyLogs(); }} onBellClick={() => setAlertDialogOpen(true)} hasMore={hasMore} loadingMore={loadingMore} onLoadMore={() => { void handleLoadMore(); }} />
|
||
)}
|
||
|
||
{/* 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={formatHistogramTick} interval="preserveStartEnd" />
|
||
<YAxis tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={false} tickLine={false} width={36} tickFormatter={formatHistogramCount} />
|
||
<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} indexedFields={indexedFields} selectedFields={selectedFields} onFieldClick={handleFieldClick} 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} onLabelFilter={handleLabelFilter} />
|
||
</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)' }} title="开启后折叠行仅显示前 500 字预览"><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)' }} title="长日志自动换行显示"><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} datasourceId={currentSource?.id} datasourceName={currentSource?.name} />
|
||
<SavedQueriesPanel visible={savedPanelOpen} onClose={() => setSavedPanelOpen(false)} onSelect={handleSavedQuerySelect} />
|
||
<SaveAlertDialog open={saveAlertOpen} onClose={() => setSaveAlertOpen(false)} engine={effEngine as LogEngine} query={query} datasourceId={currentSource?.id} 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>
|
||
);
|
||
}
|