refactor(quality): Q38 UnifiedQueryPanel 529L→353L (-33%) — 提取 useLogQueryEngine + useLogExport 自定义 Hooks,全项目最后一个 ≥500L 业务组件清零

- useLogQueryEngine.ts (295L): 查询执行引擎 Hook — 封装 loading/entries/statTable/clusters/histogramData/totalCount/queryTime/hasMore/liveTail/hasQueried 状态 + handleRun/handleLoadMore/fetchHistogram/runLiveTailTick + Live Tail useEffect(3s)
- useLogExport.ts (62L): 导出/复制 Hook — formatEntryLine + handleExportLogs(Blob) + handleCopyLogs(clipboard)
- 主文件保留: 数据源加载/URL bootstrap+sync/Loki 标签名/ES mapping effects + 派生数据 + 非查询回调 + JSX 子组件编排
- 首次使用 custom Hook 提取模式进行组件拆分
- tsc -b=0, npm build✓ (16.69s), ESLint=0/66/0, go build=0, 17/17 测试通过
This commit is contained in:
attacker committed 2026-07-10 23:01:40 +08:00
1 parent ee4d6916bb
commit f178609d09
5 files changed
+426 -216

No files matched your search

@@ -1,5 +1,7 @@
// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)
// 主文件:状态管理 + 查询逻辑 + effects + 子组件编排
// 主文件:状态管理 + 数据源/URL 同步 + 派生数据 + 子组件编排
// 查询执行逻辑提取至 ./unified-query/useLogQueryEngine.ts (Q38)
// 导出/复制逻辑提取至 ./unified-query/useLogExport.ts (Q38)
// UI 子组件提取至 ./unified-query/ 子目录
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useSearchParams, useLocation } from 'react-router-dom';
@@ -12,23 +14,22 @@ import { QuickQueryBar } from './QuickQueryBar';
import { parseLogQueryUrl, buildLogQueryParams, buildShareUrl } from './query-url-state';
import { logCenterApi, type LogDataSource } from '@/api';
import { copyToClipboard } from '@/utils/clipboard';
import { buildLogCopyText, formatLogTimestamp } from './log-format';
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 type { LogEngine, TimeRange, ResultTab, ViewMode, LogField, QueryHistoryItem, SavedQuery } from './types-v3';
import { ENGINE_META, extractFields } from './types-v3';
import { extractCommonLabels } from './EnhancedLogResults';
import { useToast } from '@/hooks/use-toast';
import { useTranslation } from 'react-i18next';
import { getErrorMessage } from '@/lib/utils';
// 提取的子组件 + 常量 + 工具函数
// 提取的子组件 + 常量 + 工具函数 + Hooks
import { DEFAULT_TIME_RANGE, DEFAULT_QUERIES, ENGINE_BADGE } from './unified-query/constants';
import { mapRawEntries, normalizeISO } from './unified-query/entry-mappers';
import { QueryControlRow } from './unified-query/QueryControlRow';
import { SecondaryToolbar } from './unified-query/SecondaryToolbar';
import { LogVolumeChart } from './unified-query/LogVolumeChart';
import { ResultsTabs } from './unified-query/ResultsTabs';
import { QueryEmptyStates } from './unified-query/QueryEmptyStates';
import { useLogQueryEngine } from './unified-query/useLogQueryEngine';
import { useLogExport } from './unified-query/useLogExport';
interface Props { engine: LogEngine; onRuleCreated: () => void; }
@@ -57,13 +58,6 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const [esIndex, setEsIndex] = useState('');
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);
@@ -77,24 +71,25 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
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);
// === 查询执行引擎 Hook(提取自 Q38)===
const {
loading, entries, statTable, clusters, histogramData,
totalCount, queryTime, hasMore, loadingMore, liveTail, setLiveTail,
hasQueried, handleRun, handleLoadMore,
} = useLogQueryEngine({
effEngine, query, currentSource, esIndex, queryLimit, timeRange, setTimeRange, toast, t,
});
// === 导出/复制 Hook(提取自 Q38)===
const { handleExportLogs, handleCopyLogs } = useLogExport({ effEngine, toast, t });
// 数据源加载后选中默认或首个;首次从 URL 恢复查询状态
useEffect(() => {
if (sources.length === 0) return;
@@ -125,10 +120,11 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
// Loki 标签名(自动补全)
useEffect(() => {
if (effEngine !== 'loki' || !currentSource?.id) return;
logCenterApi.listLokiLabels(currentSource.id, timeIsoParams())
const timeIso = { start: timeRange.start.replace(' ', 'T'), end: timeRange.end.replace(' ', 'T') };
logCenterApi.listLokiLabels(currentSource.id, timeIso)
.then((res) => setLabelNames(Array.isArray(res) ? res : []))
.catch(() => setLabelNames([]));
}, [effEngine, currentSource?.id, timeIsoParams]);
}, [effEngine, currentSource?.id, timeRange.start, timeRange.end]);
// ES mapping 索引字段(Kibana 风格侧栏)
useEffect(() => {
@@ -144,6 +140,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
.catch(() => setIndexedFields([]));
}, [effEngine, currentSource?.id, esIndex]);
// 派生数据
const fields = useMemo(() => extractFields(entries), [entries]);
const commonLabels = useMemo(() => extractCommonLabels(entries), [entries]);
const sortedEntries = useMemo(() => {
@@ -152,113 +149,9 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
}, [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 mappingFieldNames = useMemo(() => indexedFields.map((f) => f.name), [indexedFields]);
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)
const handleRun = useCallback(async () => {
if (!currentSource || !query.trim()) return;
const dsIdNum = currentSource.id;
setLoading(true);
setHasMore(false);
setNextCursor('');
setSearchAfter(undefined);
const t0 = performance.now();
try {
const startISO = normalizeISO(timeRange.start);
const endISO = normalizeISO(timeRange.end);
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, effEngine);
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: unknown) {
const msg = getErrorMessage(e);
const title = msg.includes('401') || msg.includes('403') ? t('monitor.logPanel.authFailed')
: msg.includes('404') ? t('monitor.logPanel.apiNotFound')
: msg.includes('无法连接') || msg.includes('timeout') ? t('monitor.logPanel.connFailed')
: t('monitor.logPanel.queryFailed');
toast({ title, description: msg, variant: 'destructive' });
setEntries([]); setTotalCount(0); setHasQueried(true);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effEngine, query, currentSource, timeRange, toast, queryLimit, esIndex, fetchHistogram]);
const handleLoadMore = useCallback(async () => {
if (!currentSource || !hasMore || loadingMore) return;
setLoadingMore(true);
try {
const startISO = normalizeISO(timeRange.start);
const endISO = normalizeISO(timeRange.end);
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 || [], effEngine);
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: t('monitor.logPanel.loadMoreFailed'), description: (e as Error)?.message, variant: 'destructive' });
} finally {
setLoadingMore(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSource, hasMore, loadingMore, effEngine, query, queryLimit, timeRange, esIndex, nextCursor, searchAfter, toast]);
// 非查询回调
const handleLabelFilter = useCallback((key: string, value: string, op: 'include' | 'exclude') => {
if (effEngine === 'loki') {
setQuery((q) => applyLokiLabelFilter(q, key, value, op));
@@ -270,13 +163,14 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const handleRequestLabelValues = useCallback((label: string) => {
if (!currentSource?.id) return;
const selector = extractLokiSelector(query);
const timeIso = { start: timeRange.start.replace(' ', 'T'), end: timeRange.end.replace(' ', 'T') };
logCenterApi.listLokiLabelValues(currentSource.id, label, {
...timeIsoParams(),
...timeIso,
...(selector ? { query: selector } : {}),
})
.then((res) => setLabelValues(Array.isArray(res) ? res : []))
.catch(() => setLabelValues([]));
}, [currentSource?.id, timeIsoParams, query]);
}, [currentSource?.id, timeRange.start, timeRange.end, query]);
const handleFieldClick = useCallback((field: string) => {
if (effEngine === 'elasticsearch') {
@@ -300,65 +194,11 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 || [], effEngine);
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]);
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);
@@ -375,31 +215,15 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [histogramData]);
const formatEntryLine = useCallback((e: LogEntry) => buildLogCopyText(e), []);
const handleExportLogs = useCallback(() => {
const text = sortedEntries.map(formatEntryLine).join('\n');
const b = new Blob([text], { 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: t('monitor.logPanel.exported'), description: t('monitor.logPanel.exportedDesc', { count: sortedEntries.length }) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortedEntries, effEngine, formatEntryLine, toast]);
const handleCopyLogs = useCallback(async () => {
const text = pagedEntries.map(formatEntryLine).join('\n');
const ok = await copyToClipboard(text);
if (ok) {
toast({ title: t('monitor.logPanel.copied'), description: t('monitor.logPanel.copiedDesc', { count: pagedEntries.length }) });
} else {
toast({ title: t('monitor.logPanel.copyFailed'), description: t('monitor.logPanel.copyFailedDesc'), variant: 'destructive' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagedEntries, formatEntryLine, toast]);
// 自动运行(URL ?run=1)
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]);
return (
<div className="space-y-3">
@@ -467,7 +291,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
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(); }}
onExport={() => handleExportLogs(sortedEntries)} onCopy={() => { void handleCopyLogs(pagedEntries); }}
onBellClick={() => setAlertDialogOpen(true)}
hasMore={hasMore} loadingMore={loadingMore} onLoadMore={() => { void handleLoadMore(); }}
/>
@@ -0,0 +1,62 @@
// useLogExport.ts — 日志导出/复制工具 Hook
// 封装 UnifiedQueryPanel 的导出/复制逻辑
// 提取自 UnifiedQueryPanel.tsx (Q38),零逻辑变更
import { useCallback } from 'react';
import { copyToClipboard } from '@/utils/clipboard';
import { buildLogCopyText } from '../log-format';
import type { LogEntry } from '../types-v3';
interface UseLogExportOpts {
effEngine: 'loki' | 'elasticsearch';
toast: (opts: { title: string; description?: string; variant?: 'default' | 'destructive' }) => void;
t: (key: string, opts?: Record<string, unknown>) => string;
}
export function useLogExport(opts: UseLogExportOpts) {
const { effEngine, toast, t } = opts;
const formatEntryLine = useCallback((e: LogEntry) => buildLogCopyText(e), []);
const handleExportLogs = useCallback(
(sortedEntries: LogEntry[]) => {
const text = sortedEntries.map(formatEntryLine).join('\n');
const b = new Blob([text], { 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: t('monitor.logPanel.exported'),
description: t('monitor.logPanel.exportedDesc', { count: sortedEntries.length }),
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[effEngine, formatEntryLine, toast]
);
const handleCopyLogs = useCallback(
async (pagedEntries: LogEntry[]) => {
const text = pagedEntries.map(formatEntryLine).join('\n');
const ok = await copyToClipboard(text);
if (ok) {
toast({
title: t('monitor.logPanel.copied'),
description: t('monitor.logPanel.copiedDesc', { count: pagedEntries.length }),
});
} else {
toast({
title: t('monitor.logPanel.copyFailed'),
description: t('monitor.logPanel.copyFailedDesc'),
variant: 'destructive',
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[formatEntryLine, toast]
);
return { formatEntryLine, handleExportLogs, handleCopyLogs };
}
@@ -0,0 +1,295 @@
// useLogQueryEngine.ts — 日志查询执行引擎 Hook
// 封装 UnifiedQueryPanel 的查询状态机:执行查询 / 加载更多 / 直方图 / Live Tail
// 提取自 UnifiedQueryPanel.tsx (Q38),零逻辑变更
import { useState, useCallback, useEffect } from 'react';
import { logCenterApi, type LogDataSource } from '@/api';
import { mapRawEntries, normalizeISO } from './entry-mappers';
import { formatLogTimestamp } from '../log-format';
import {
buildStatTableFromEntries,
buildClustersFromEntries,
generateHistogramData,
historyStorage,
} from '../types-v3';
import type { LogEngine, TimeRange, LogEntry, StatTable, LogCluster } from '../types-v3';
import { getErrorMessage } from '@/lib/utils';
interface UseLogQueryEngineOpts {
effEngine: 'loki' | 'elasticsearch';
query: string;
currentSource: LogDataSource | undefined;
esIndex: string;
queryLimit: number;
timeRange: TimeRange;
setTimeRange: (tr: TimeRange) => void;
toast: (opts: { title: string; description?: string; variant?: 'default' | 'destructive' }) => void;
t: (key: string, opts?: Record<string, unknown>) => string;
}
export function useLogQueryEngine(opts: UseLogQueryEngineOpts) {
const { effEngine, query, currentSource, esIndex, queryLimit, timeRange, setTimeRange, toast, t } = opts;
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 [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 [hasQueried, setHasQueried] = useState(false);
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]
);
const handleRun = useCallback(async () => {
if (!currentSource || !query.trim()) return;
const dsIdNum = currentSource.id;
setLoading(true);
setHasMore(false);
setNextCursor('');
setSearchAfter(undefined);
const t0 = performance.now();
try {
const startISO = normalizeISO(timeRange.start);
const endISO = normalizeISO(timeRange.end);
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, effEngine);
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));
setHasQueried(true);
historyStorage.add({
engine: effEngine as LogEngine,
datasourceName: currentSource.name,
query,
timeRange: timeRange.label,
resultCount: mapped.length,
});
} catch (e: unknown) {
const msg = getErrorMessage(e);
const title =
msg.includes('401') || msg.includes('403')
? t('monitor.logPanel.authFailed')
: msg.includes('404')
? t('monitor.logPanel.apiNotFound')
: msg.includes('无法连接') || msg.includes('timeout')
? t('monitor.logPanel.connFailed')
: t('monitor.logPanel.queryFailed');
toast({ title, description: msg, variant: 'destructive' });
setEntries([]);
setTotalCount(0);
setHasQueried(true);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effEngine, query, currentSource, timeRange, toast, queryLimit, esIndex, fetchHistogram]);
const handleLoadMore = useCallback(async () => {
if (!currentSource || !hasMore || loadingMore) return;
setLoadingMore(true);
try {
const startISO = normalizeISO(timeRange.start);
const endISO = normalizeISO(timeRange.end);
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 || [], effEngine);
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: t('monitor.logPanel.loadMoreFailed'),
description: (e as Error)?.message,
variant: 'destructive',
});
} finally {
setLoadingMore(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
currentSource,
hasMore,
loadingMore,
effEngine,
query,
queryLimit,
timeRange,
esIndex,
nextCursor,
searchAfter,
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 || [], effEngine);
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, setTimeRange]);
useEffect(() => {
if (!liveTail) return undefined;
void runLiveTailTick();
const id = setInterval(() => {
void runLiveTailTick();
}, 3000);
return () => clearInterval(id);
}, [liveTail, runLiveTailTick]);
return {
loading,
entries,
statTable,
clusters,
histogramData,
totalCount,
queryTime,
hasMore,
loadingMore,
liveTail,
setLiveTail,
hasQueried,
handleRun,
handleLoadMore,
};
}