refactor(quality): Q13 前端巨型组件拆分 — UnifiedQueryPanel 682L→529L (-22%), 提取 constants + entry-mappers + 5 个 UI 子组件 (QueryControlRow/SecondaryToolbar/LogVolumeChart/ResultsTabs/QueryEmptyStates), 零逻辑变更

This commit is contained in:
attacker committed 2026-07-07 20:23:57 +08:00
1 parent b2e8fbbfc0
commit 8a11acd086
11 files changed
+660 -276

No files matched your search

@@ -1,53 +1,34 @@
// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)// Logan.Li
// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)
// 主文件:状态管理 + 查询逻辑 + effects + 子组件编排
// UI 子组件提取至 ./unified-query/ 子目录
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 { logCenterApi, type LogDataSource } from '@/api';
import { copyToClipboard } from '@/utils/clipboard';
import { buildLogCopyText, formatHistogramCount, formatHistogramTick, formatLogTimestamp } from './log-format';
import { EsIndexPicker } from './EsIndexPicker';
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 { extractCommonLabels } from './EnhancedLogResults';
import { useToast } from '@/hooks/use-toast';
import { useTranslation } from 'react-i18next';
import { getErrorMessage } from '@/lib/utils';
// 默认最近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: 'Last 5 min',
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' },
};
// 提取的子组件 + 常量 + 工具函数
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';
interface Props { engine: LogEngine; onRuleCreated: () => void; }
@@ -62,7 +43,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const meta = ENGINE_META[effEngine];
const badge = ENGINE_BADGE[effEngine];
// 从后端加载真实数据源(无 demo 兜底,无源时引导用户前往日志库配置)// Logan.Li
// 从后端加载真实数据源(无 demo 兜底,无源时引导用户前往日志库配置)
const [sources, setSources] = useState<LogDataSource[]>([]);
useEffect(() => {
logCenterApi.listDatasources(effEngine).then((res) => {
@@ -73,9 +54,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
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);
@@ -116,7 +95,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const currentSource = sources.find((s) => String(s.id) === dsId);
// 数据源加载后选中默认或首个;首次从 URL 恢复查询状态 // Logan.Li
// 数据源加载后选中默认或首个;首次从 URL 恢复查询状态
useEffect(() => {
if (sources.length === 0) return;
if (!urlBootstrapped.current) {
@@ -134,19 +113,16 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
if (!dsId && def) setDsId(String(def.id));
}, [sources, searchParams, dsId]);
// 查询状态同步到 URL(可分享)// Logan.Li
// 查询状态同步到 URL(可分享)
useEffect(() => {
if (!urlBootstrapped.current) return;
const p = buildLogQueryParams({
query,
datasourceId: dsId || undefined,
index: esIndex || undefined,
timeRange,
query, datasourceId: dsId || undefined, index: esIndex || undefined, timeRange,
});
setSearchParams(p, { replace: true });
}, [query, dsId, esIndex, timeRange, setSearchParams]);
// Loki 标签名(自动补全)// Logan.Li
// Loki 标签名(自动补全)
useEffect(() => {
if (effEngine !== 'loki' || !currentSource?.id) return;
logCenterApi.listLokiLabels(currentSource.id, timeIsoParams())
@@ -154,7 +130,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
.catch(() => setLabelNames([]));
}, [effEngine, currentSource?.id, timeIsoParams]);
// ES mapping 索引字段(Kibana 风格侧栏)// Logan.Li
// ES mapping 索引字段(Kibana 风格侧栏)
useEffect(() => {
if (effEngine !== 'elasticsearch' || !currentSource?.id) return;
logCenterApi.esMapping({ datasource_id: currentSource.id, index: esIndex || undefined })
@@ -170,65 +146,35 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
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 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,
})));
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 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,
})));
setHistogramData(buckets.map((b) => ({ timestamp: formatLogTimestamp(b.timestamp), count: b.count })));
return;
}
}
} catch { /* 直方图失败时回退客户端聚合 */ }
}, [effEngine, query, esIndex]);
// 执行查询 — 接入真实 API(无 mock)// Logan.Li
// 执行查询 — 接入真实 API(无 mock)
const handleRun = useCallback(async () => {
// 空查询兜底:后端 Query 字段 required,空值会 400,这里提前拦截 // Logan.Li
if (!currentSource || !query.trim()) return;
const dsIdNum = currentSource.id;
setLoading(true);
@@ -238,8 +184,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
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 startISO = normalizeISO(timeRange.start);
const endISO = normalizeISO(timeRange.end);
const data = effEngine === 'loki'
? await logCenterApi.queryLoki({
@@ -250,7 +196,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
});
const rawEntries = data?.entries || [];
const mapped = mapRawEntries(rawEntries);
const mapped = mapRawEntries(rawEntries, effEngine);
setEntries(mapped);
void fetchHistogram(dsIdNum, startISO, endISO);
@@ -277,14 +223,14 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effEngine, query, currentSource, timeRange, toast, queryLimit, esIndex, mapRawEntries, fetchHistogram]);
}, [effEngine, query, currentSource, timeRange, toast, queryLimit, esIndex, 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 startISO = normalizeISO(timeRange.start);
const endISO = normalizeISO(timeRange.end);
const dsIdNum = currentSource.id;
const data = effEngine === 'loki'
? await logCenterApi.queryLoki({
@@ -295,7 +241,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
datasource_id: dsIdNum, index: esIndex || undefined, query, size: queryLimit,
start: startISO, end: endISO, search_after: searchAfter,
});
const more = mapRawEntries(data?.entries || []);
const more = mapRawEntries(data?.entries || [], effEngine);
setEntries((prev) => {
const merged = [...prev, ...more];
setStatTable(buildStatTableFromEntries(merged));
@@ -311,7 +257,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
setLoadingMore(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSource, hasMore, loadingMore, effEngine, query, queryLimit, timeRange, esIndex, nextCursor, searchAfter, mapRawEntries, toast]);
}, [currentSource, hasMore, loadingMore, effEngine, query, queryLimit, timeRange, esIndex, nextCursor, searchAfter, toast]);
const handleLabelFilter = useCallback((key: string, value: string, op: 'include' | 'exclude') => {
if (effEngine === 'loki') {
@@ -375,7 +321,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
: await logCenterApi.queryElasticsearch({
datasource_id: currentSource.id, index: esIndex || undefined, query, size: 200, start: startISO, end: endISO,
});
const mapped = mapRawEntries(data?.entries || []);
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}`));
@@ -388,7 +334,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
return merged;
});
} catch { /* Live tail 静默失败 */ }
}, [currentSource, query, effEngine, esIndex, mapRawEntries]);
}, [currentSource, query, effEngine, esIndex]);
useEffect(() => {
if (!liveTail) return undefined;
@@ -416,11 +362,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const toggleField = useCallback((n: string) => setSelectedFields(p => p.includes(n) ? p.filter(f => f !== n) : [...p, n]), []);
const handleSaveMenu = (a: 'query' | 'alert') => {
setSaveMenuOpen(false);
if (a === 'query') {
setSaveQueryOpen(true);
} else {
setSaveAlertOpen(true);
}
if (a === 'query') setSaveQueryOpen(true);
else setSaveAlertOpen(true);
};
const handleChartClick = useCallback((st: { activeLabel?: string }) => {
@@ -460,99 +403,45 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
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" />} {t('monitor.logPanel.query')}
</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={t('monitor.logPanel.liveTailTitle')}
>
<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} />
<QueryControlRow
badge={badge}
query={query}
onQueryChange={setQuery}
effEngine={effEngine}
metaQueryPlaceholder={meta.queryPlaceholder}
labelNames={labelNames}
labelValues={labelValues}
mappingFields={mappingFieldNames}
onRequestLabelValues={handleRequestLabelValues}
loading={loading}
liveTail={liveTail}
hasSource={!!currentSource}
onRun={() => { setLiveTail(false); void handleRun(); }}
onToggleLiveTail={() => setLiveTail((v) => !v)}
/>
<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="">{t('monitor.logPanel.noDatasource', { name: meta.name })}</option>}
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? t('monitor.logPanel.defaultSuffix') : ''}</option>)}
</select>
<SecondaryToolbar
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
dsId={dsId}
onDsIdChange={setDsId}
sources={sources}
engineName={meta.name}
effEngine={effEngine}
currentSourceId={currentSource?.id}
esIndex={esIndex}
onEsIndexChange={setEsIndex}
queryLimit={queryLimit}
onQueryLimitChange={setQueryLimit}
saveMenuOpen={saveMenuOpen}
setSaveMenuOpen={setSaveMenuOpen}
onSaveMenuSelect={handleSaveMenu}
onShareLink={() => { void handleShareLink(); }}
onOpenSaved={() => setSavedPanelOpen(true)}
onOpenHistory={() => setHistoryPanelOpen(true)}
/>
{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)]">{t('monitor.logPanel.limit')}</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" /> {t('monitor.logPanel.saveAs')} <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" /> {t('monitor.logPanel.saveAsQueryItem')}</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" /> {t('monitor.logPanel.saveAsAlertItem')}</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={t('monitor.logPanel.shareLinkTitle')}>
<Link2 className="h-3.5 w-3.5" /> {t('monitor.logPanel.share')}
</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={t('monitor.logPanel.savedQueries')}>
<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={t('monitor.logPanel.history')}>
<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' }}>
{t('monitor.logPanel.noDatasourceHint', { name: meta.name })}
@@ -573,103 +462,61 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
{/* 统计条 + 工具栏 */}
{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(); }} />
<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} ${t('monitor.logPanel.chartCountSuffix')}`, t('monitor.logPanel.chartCountLabel')]} labelFormatter={(l: string) => `${t('monitor.logPanel.chartTimePrefix')} ${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>
<LogVolumeChart effEngine={effEngine} histogramData={histogramData} color={meta.color} onChartClick={handleChartClick} />
)}
{/* 结果标签页 */}
{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: t('monitor.logPanel.tabLogs'), stats: t('monitor.logPanel.tabStats'), cluster: t('monitor.logPanel.tabCluster') };
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' ? t('monitor.logPanel.viewRaw') : m === 'field' ? t('monitor.logPanel.viewField') : t('monitor.logPanel.viewTable')}
</button>
))}
</div>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor.logPanel.truncateTitle')}><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor.logPanel.truncate')}</label>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor.logPanel.wrapLinesTitle')}><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor.logPanel.wrapLines')}</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>
<ResultsTabs
resultTab={resultTab}
setResultTab={setResultTab}
effEngine={effEngine}
metaColor={meta.color}
fields={fields}
indexedFields={indexedFields}
selectedFields={selectedFields}
onFieldClick={handleFieldClick}
onToggleDisplayField={toggleField}
loading={loading}
pagedEntries={pagedEntries}
viewMode={viewMode}
setViewMode={setViewMode}
wrapLines={wrapLines}
setWrapLines={setWrapLines}
truncate={truncate}
setTruncate={setTruncate}
commonLabels={commonLabels}
onLabelFilter={handleLabelFilter}
statTable={statTable}
clusters={clusters}
query={query}
onSaveAsAlert={() => setSaveAlertOpen(true)}
onSaveAsQuery={() => setSaveQueryOpen(true)}
/>
)}
{/* 空状态:从未查询 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)' }}>{t('monitor.logPanel.emptyHint')}</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)' }}>{t('monitor.logPanel.noMatch')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--dg-text-muted)' }}>
{t('monitor.logPanel.noMatchDesc', { ms: queryTime })}
</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" />{t('monitor.logPanel.requeries')}
</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>
)}
{/* 空状态 */}
<QueryEmptyStates
loading={loading}
entriesCount={entries.length}
hasQueried={hasQueried}
queryTime={queryTime}
metaQueryPlaceholder={meta.queryPlaceholder}
onRequery={handleRun}
/>
{/* 对话框 */}
<SaveQueryDialog open={saveQueryOpen} onClose={() => setSaveQueryOpen(false)} engine={effEngine as LogEngine} query={query} timeRange={timeRange} datasourceId={currentSource?.id} datasourceName={currentSource?.name} />
@@ -0,0 +1,38 @@
// unified-query/LogVolumeChart.tsx — Log Volume 面积图(直方图 + 点击缩时)
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { formatHistogramCount, formatHistogramTick } from '../log-format';
import { useTranslation } from 'react-i18next';
interface LogVolumeChartProps {
effEngine: 'loki' | 'elasticsearch';
histogramData: { timestamp: string; count: number }[];
color: string;
onChartClick: (st: { activeLabel?: string }) => void;
}
export function LogVolumeChart({ effEngine, histogramData, color, onChartClick }: LogVolumeChartProps) {
const { t } = useTranslation();
return (
<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={onChartClick}>
<defs>
<linearGradient id={`vol-${effEngine}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.35} />
<stop offset="100%" stopColor={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} ${t('monitor.logPanel.chartCountSuffix')}`, t('monitor.logPanel.chartCountLabel')]}
labelFormatter={(l: string) => `${t('monitor.logPanel.chartTimePrefix')} ${l}`}
/>
<Area type="monotone" dataKey="count" stroke={color} fill={`url(#vol-${effEngine})`} strokeWidth={1.5} dot={false} activeDot={{ r: 4, stroke: color, strokeWidth: 2, fill: 'var(--dg-bg-secondary)' }} />
</AreaChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,68 @@
// unified-query/QueryControlRow.tsx — 查询控制行(引擎徽标 + 输入框 + 查询按钮 + Live Tail)
import { Play, Loader2, Radio } from 'lucide-react';
import { QueryAutocompleteInput } from '../QueryAutocompleteInput';
import { useTranslation } from 'react-i18next';
interface QueryControlRowProps {
badge: { label: string; dotColor: string };
query: string;
onQueryChange: (q: string) => void;
effEngine: 'loki' | 'elasticsearch';
metaQueryPlaceholder: string;
labelNames: string[];
labelValues: string[];
mappingFields: string[];
onRequestLabelValues: (label: string) => void;
loading: boolean;
liveTail: boolean;
hasSource: boolean;
onRun: () => void;
onToggleLiveTail: () => void;
}
export function QueryControlRow({
badge, query, onQueryChange, effEngine, metaQueryPlaceholder,
labelNames, labelValues, mappingFields, onRequestLabelValues,
loading, liveTail, hasSource, onRun, onToggleLiveTail,
}: QueryControlRowProps) {
const { t } = useTranslation();
const canQuery = hasSource && query.trim().length > 0;
return (
<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={onQueryChange}
engine={effEngine}
placeholder={metaQueryPlaceholder}
labelNames={labelNames}
labelValues={labelValues}
mappingFields={mappingFields}
onRequestLabelValues={onRequestLabelValues}
/>
<button onClick={onRun} disabled={loading || liveTail || !canQuery}
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" />} {t('monitor.logPanel.query')}
</button>
<button
onClick={onToggleLiveTail}
disabled={!canQuery}
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={t('monitor.logPanel.liveTailTitle')}
>
<Radio className={`h-3.5 w-3.5 ${liveTail ? 'animate-pulse' : ''}`} /> Tail
</button>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
// unified-query/QueryEmptyStates.tsx — 查询空状态(未查询 / 0 结果 / loading)
import { Play, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface QueryEmptyStatesProps {
loading: boolean;
entriesCount: number;
hasQueried: boolean;
queryTime: number;
metaQueryPlaceholder: string;
onRequery: () => void;
}
export function QueryEmptyStates(props: QueryEmptyStatesProps) {
const { loading, entriesCount, hasQueried, queryTime, metaQueryPlaceholder, onRequery } = props;
const { t } = useTranslation();
// 未查询
if (!loading && entriesCount === 0 && !hasQueried) {
return (
<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)' }}>{t('monitor.logPanel.emptyHint')}</span>
<span className="text-xs font-mono px-3 py-1 rounded" style={{ color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>{metaQueryPlaceholder}</span>
</div>
);
}
// 查询返回 0 结果
if (!loading && entriesCount === 0 && hasQueried) {
return (
<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)' }}>{t('monitor.logPanel.noMatch')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--dg-text-muted)' }}>
{t('monitor.logPanel.noMatchDesc', { ms: queryTime })}
</p>
</div>
<button onClick={onRequery}
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" />{t('monitor.logPanel.requeries')}
</button>
</div>
);
}
// Loading
if (loading && entriesCount === 0) {
return (
<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>
);
}
return null;
}
@@ -0,0 +1,103 @@
// unified-query/ResultsTabs.tsx — 结果标签页(logs/stats/cluster)+ logs tab 视图控制
import { LogFieldSidebar } from '../LogFieldSidebar';
import { EnhancedLogResults } from '../EnhancedLogResults';
import { StatChartPanel } from '../StatChartPanel';
import { LogClusterPanel } from '../LogClusterPanel';
import { useTranslation } from 'react-i18next';
import type { LogEngine, ResultTab, ViewMode, LogEntry, LogField, StatTable, LogCluster } from '../types-v3';
interface ResultsTabsProps {
resultTab: ResultTab;
setResultTab: (tab: ResultTab) => void;
effEngine: 'loki' | 'elasticsearch';
metaColor: string;
// logs tab
fields: LogField[];
indexedFields: LogField[];
selectedFields: string[];
onFieldClick: (field: string) => void;
onToggleDisplayField: (n: string) => void;
loading: boolean;
pagedEntries: LogEntry[];
viewMode: ViewMode;
setViewMode: (m: ViewMode) => void;
wrapLines: boolean;
setWrapLines: (v: boolean) => void;
truncate: boolean;
setTruncate: (v: boolean) => void;
commonLabels: ReturnType<typeof import('../EnhancedLogResults').extractCommonLabels>;
onLabelFilter: (key: string, value: string, op: 'include' | 'exclude') => void;
// stats / cluster tab
statTable?: StatTable;
clusters: LogCluster[];
query: string;
onSaveAsAlert: () => void;
onSaveAsQuery: () => void;
}
export function ResultsTabs(props: ResultsTabsProps) {
const { t } = useTranslation();
const {
resultTab, setResultTab, metaColor,
fields, indexedFields, selectedFields, onFieldClick, onToggleDisplayField, loading,
pagedEntries, viewMode, setViewMode, wrapLines, setWrapLines, truncate, setTruncate,
commonLabels, onLabelFilter,
statTable, clusters, query, onSaveAsAlert, onSaveAsQuery,
} = props;
const effEngine = props.effEngine as LogEngine;
const labels: Record<ResultTab, string> = {
logs: t('monitor.logPanel.tabLogs'),
stats: t('monitor.logPanel.tabStats'),
cluster: t('monitor.logPanel.tabCluster'),
};
return (
<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 => (
<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 ? metaColor : 'var(--dg-text-muted)',
background: resultTab === tab ? 'var(--dg-bg-secondary)' : 'transparent',
borderBottom: resultTab === tab ? `2px solid ${metaColor}` : '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={onFieldClick} onToggleDisplayField={onToggleDisplayField} loading={loading} />
<div className="flex-1 min-w-0">
<EnhancedLogResults entries={pagedEntries} loading={loading} viewMode={viewMode} wrapLines={wrapLines} truncate={truncate} commonLabels={commonLabels || undefined} onLabelFilter={onLabelFilter} />
</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 ? metaColor : 'var(--dg-bg-tertiary)', color: viewMode === m ? '#fff' : 'var(--dg-text-muted)' }}>
{m === 'raw' ? t('monitor.logPanel.viewRaw') : m === 'field' ? t('monitor.logPanel.viewField') : t('monitor.logPanel.viewTable')}
</button>
))}
</div>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor.logPanel.truncateTitle')}>
<input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor.logPanel.truncate')}
</label>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor.logPanel.wrapLinesTitle')}>
<input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor.logPanel.wrapLines')}
</label>
</div>
</>)}
{resultTab === 'stats' && <StatChartPanel statTable={statTable} engine={effEngine} query={query} onSaveAsAlert={onSaveAsAlert} onSaveAsQuery={onSaveAsQuery} />}
{resultTab === 'cluster' && <LogClusterPanel clusters={clusters} />}
</div>
</div>
);
}
@@ -0,0 +1,112 @@
// unified-query/SecondaryToolbar.tsx — 次级控件行(时间/数据源/索引/上限/另存为/分享/收藏/历史)
import { Save, ChevronDown, Bell, Link2, Bookmark, Clock } from 'lucide-react';
import { TimeRangePicker } from '../TimeRangePicker';
import { EsIndexPicker } from '../EsIndexPicker';
import { useTranslation } from 'react-i18next';
import type { LogDataSource } from '@/api';
import type { TimeRange } from '../types-v3';
interface SecondaryToolbarProps {
timeRange: TimeRange;
onTimeRangeChange: (tr: TimeRange) => void;
dsId: string;
onDsIdChange: (id: string) => void;
sources: LogDataSource[];
engineName: string;
effEngine: 'loki' | 'elasticsearch';
currentSourceId: number | undefined;
esIndex: string;
onEsIndexChange: (idx: string) => void;
queryLimit: number;
onQueryLimitChange: (n: number) => void;
saveMenuOpen: boolean;
setSaveMenuOpen: (open: boolean) => void;
onSaveMenuSelect: (action: 'query' | 'alert') => void;
onShareLink: () => void;
onOpenSaved: () => void;
onOpenHistory: () => void;
}
export function SecondaryToolbar(props: SecondaryToolbarProps) {
const {
timeRange, onTimeRangeChange, dsId, onDsIdChange, sources, engineName,
effEngine, currentSourceId, esIndex, onEsIndexChange,
queryLimit, onQueryLimitChange,
saveMenuOpen, setSaveMenuOpen, onSaveMenuSelect,
onShareLink, onOpenSaved, onOpenHistory,
} = props;
const { t } = useTranslation();
return (
<div className="flex items-center gap-2 flex-wrap">
<TimeRangePicker value={timeRange} onChange={onTimeRangeChange} />
<select value={dsId} onChange={e => onDsIdChange(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="">{t('monitor.logPanel.noDatasource', { name: engineName })}</option>}
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? t('monitor.logPanel.defaultSuffix') : ''}</option>)}
</select>
{effEngine === 'elasticsearch' && (
<EsIndexPicker datasourceId={currentSourceId} value={esIndex} onChange={onEsIndexChange} />
)}
<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)]">{t('monitor.logPanel.limit')}</span>
<input
type="number" min={1} max={1000} value={queryLimit}
onChange={e => {
const v = parseInt(e.target.value, 10);
onQueryLimitChange(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" /> {t('monitor.logPanel.saveAs')} <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={() => onSaveMenuSelect('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" /> {t('monitor.logPanel.saveAsQueryItem')}
</button>
<button onClick={() => onSaveMenuSelect('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" /> {t('monitor.logPanel.saveAsAlertItem')}
</button>
</div>
</>)}
</div>
<button onClick={onShareLink}
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={t('monitor.logPanel.shareLinkTitle')}>
<Link2 className="h-3.5 w-3.5" /> {t('monitor.logPanel.share')}
</button>
<button onClick={onOpenSaved}
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={t('monitor.logPanel.savedQueries')}>
<Bookmark className="h-4 w-4" />
</button>
<button onClick={onOpenHistory}
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={t('monitor.logPanel.history')}>
<Clock className="h-4 w-4" />
</button>
</div>
);
}
@@ -0,0 +1,23 @@
// unified-query/constants.ts — UnifiedQueryPanel 静态常量
// 引擎默认查询语句、徽标配色、默认时间范围
import type { TimeRange } from '../types-v3';
// 默认最近 5 分钟
export 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: 'Last 5 min',
type: 'relative',
};
// 引擎默认查询语句:Loki 用 LogQL / ES 用 Lucene,对齐平台 admin-api 服务
export const DEFAULT_QUERIES: Record<'loki' | 'elasticsearch', string> = {
loki: '{app="admin-api"} |= "INFO"',
elasticsearch: 'app:admin-api',
};
// 引擎徽标:左侧小圆点 + 语言标签
export const ENGINE_BADGE: Record<'loki' | 'elasticsearch', { label: string; dotColor: string }> = {
loki: { label: 'LogQL', dotColor: '#06b6d4' },
elasticsearch: { label: 'Lucene', dotColor: '#22c55e' },
};
@@ -0,0 +1,44 @@
// unified-query/entry-mappers.ts — LogQueryEntry → LogEntry 映射 + 时间格式化辅助
// 从 UnifiedQueryPanel 提取的纯函数(无 React 依赖)
import { formatLogTimestamp } from '../log-format';
import type { LogEntry } from '../types-v3';
import type { LogQueryEntry } from '@/api';
/**
* 将后端 LogQueryEntry 数组映射为前端 LogEntry 数组:
* - 优先使用后端返回的 level 字段
* - ES 引擎额外尝试从 raw JSON 解析 level
* - 兜底用正则匹配日志级别关键字
*/
export function mapRawEntries(rawEntries: LogQueryEntry[], effEngine: 'loki' | 'elasticsearch'): LogEntry[] {
return 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 || '',
};
});
}
/**
* ISO 时间字符串规范化:补全毫秒和 Z 后缀(ES 7.x strict_date_optional_time 要求 .SSSZ 格式)
*/
export function normalizeISO(time: string): string {
return (time + (time.includes('T') ? '' : '.000Z')).replace(' ', 'T');
}