feat: 工具配置增强、站点拨测与日志中心多项改进

- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖
- 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败
- Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS
- 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率
- 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示
- 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
This commit is contained in:
Logan committed 2026-07-02 07:47:33 +08:00
1 parent cde7bbe540
commit 7e1c8f583c
84 files changed
+7729 -1507

No files matched your search

@@ -1,6 +1,7 @@
// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)// Logan.Li
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { Play, Loader2, Clock, Save, ChevronDown, Bell } from 'lucide-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';
@@ -10,9 +11,18 @@ 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 { logCenterApi, type LogDataSource } from '@/api';
import type { LogEngine, TimeRange, ResultTab, ViewMode, LogEntry, StatTable, LogCluster, QueryHistoryItem } from './types-v3';
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';
@@ -37,104 +47,14 @@ const ENGINE_BADGE: Record<'loki' | 'elasticsearch', { label: string; dotColor:
elasticsearch: { label: 'Lucene', dotColor: '#22c55e' },
};
// 增强快捷查询模板(14+ 条/引擎) // Logan.Li
const ALL_TEMPLATES: Record<'loki' | 'elasticsearch', { label: string; query: string }[]> = {
loki: [
{ label: 'Nginx 错误', query: '{job="nginx"} |= "error"' },
{ label: 'JSON 过滤', query: '{namespace="prod"} | json | level="warn"' },
{ label: '超时搜索', query: '{app="api"} |= "timeout"' },
{ label: '级别统计', query: 'sum by(level) (count_over_time({job="app"}[5m]))' },
{ label: '正则多级别', query: '{container="frontend"} |~ "ERROR|WARN|FATAL"' },
{ label: '慢请求>1s', query: '{job="api"} | json | response_time > 1000' },
{ label: '错误率统计', query: 'sum by(level) (count_over_time({app="cex-admin-api"} |= "error" [5m]))' },
{ label: '追踪ID查询', query: '{app="cex-admin-api"} |= "trace_id" | json' },
{ label: '服务聚合', query: 'sum by(service_name) (count_over_time({namespace="astralx"}[5m]))' },
{ label: '高延迟筛选', query: '{app="cex-admin-api"} | json | response_time > 500' },
{ label: '异常堆栈', query: '{app="cex-admin-api"} |= "Exception"' },
{ label: '日志趋势', query: 'sum(count_over_time({app="cex-admin-api"}[1h]))' },
{ label: '正则提取', query: '{app="cex-admin-api"} | regexp "(?P<method>[A-Za-z0-9_]+) (?P<path>[^ ]+)"' }, // LogQL regexp 用 RE2,不支持 \w \S 等 Perl 简写,须用显式字符类,否则 Loki 返回 400 invalid char escape // Logan.Li
{ label: '格式输出', query: '{app="cex-admin-api"} | json | line_format "{{.level}} {{.message}}"' },
],
elasticsearch: [
{ label: 'API 5xx', query: 'status:500 AND path:"/api/*"' },
{ label: '错误超时', query: 'level:error AND message:*timeout*' },
{ label: '排除调试', query: 'host.name:server-* AND NOT level:debug' },
{ label: '慢请求', query: 'response_time:>1000 AND method:POST' },
{ label: '严重错误', query: '@timestamp:[now-1h TO now] AND level:critical' },
{ label: '服务错误', query: 'service:nginx AND (level:error OR level:fatal)' },
{ label: '组合查询', query: 'app:cex-admin-api AND (level:error OR level:warn)' },
{ label: '模糊匹配', query: 'message:*timeout* AND app:cex-admin-api' },
{ label: '范围查询', query: 'response_time:[1000 TO *] AND method:POST' },
{ label: '排除路径', query: 'app:cex-admin-api AND NOT path:/health' },
{ label: '时间过滤', query: '@timestamp:[now-1h TO now] AND level:error' },
{ label: 'IP段过滤', query: 'client_ip:172.36.* AND app:cex-admin-api' },
{ label: '聚合统计', query: 'app:cex-admin-api | STATS count(*) by level' },
{ label: '嵌套字段', query: 'labels.orgId:10001 AND app:cex-admin-api' },
],
};
// LogQL / Lucene 语法高亮 → HTML // Logan.Li
function highlightSyntax(query: string, engine: 'loki' | 'elasticsearch'): string {
if (!query) return '<span style="color:transparent">.</span>';
// 先转义 HTML 特殊字符,避免查询语句中的 < > & 破坏标签结构 // Logan.Li
const esc = query.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const span = (color: string, text: string) => `<span style="color:${color}">${text}</span>`;
if (engine === 'loki') {
// 单正则 + 回调:一次匹配,避免链式 replace 对已生成颜色值的二次匹配 // Logan.Li
return esc.replace(
/("(?:[^"\\]|\\.)*")|([a-zA-Z_][\w-]*)(?==)|(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)|\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b|([{}])|(\|=|\!=|=~|!~|=)/g,
(m, str, ident, pipe, fn, brace, op) => {
if (str) return span('#22c55e', str);
if (ident) return span('#f59e0b', ident);
if (pipe) return span('#8b5cf6', pipe);
if (fn) return span('#06b6d4', fn);
if (brace) return span('#f59e0b', brace);
if (op) return span('#f43f5e', op);
return m;
},
) + '<span style="color:transparent">.</span>';
}
return esc.replace(
/("(?:[^"\\]|\\.)*")|\b(AND|OR|NOT)\b|([a-zA-Z_@][\w.-]*)(?=:)|([*?~^[\]()])/g,
(m, str, kw, field, punct) => {
if (str) return span('#22c55e', str);
if (kw) return span('#f43f5e', kw);
if (field) return span('#f59e0b', field);
if (punct) return span('#8b5cf6', punct);
return m;
},
) + '<span style="color:transparent">.</span>';
}
// 语法高亮输入框:底层高亮 + 上层透明输入 // Logan.Li
function SyntaxHighlightInput({ value, onChange, engine, placeholder }: {
value: string; onChange: (v: string) => void; engine: 'loki' | 'elasticsearch'; placeholder?: string;
}) {
const taRef = useRef<HTMLTextAreaElement>(null);
const preRef = useRef<HTMLPreElement>(null);
const sync = useCallback(() => {
if (preRef.current && taRef.current) { preRef.current.scrollLeft = taRef.current.scrollLeft; preRef.current.scrollTop = taRef.current.scrollTop; }
}, []);
const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') { e.preventDefault(); const t = e.currentTarget, s = t.selectionStart, ed = t.selectionEnd; onChange(value.slice(0, s) + ' ' + value.slice(ed)); requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 2; }); }
};
const html = useMemo(() => highlightSyntax(value, engine), [value, engine]);
return (
<div className="relative flex-1 min-w-[120px]" style={{ height: '40px', background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', borderRadius: '0.25rem' }}>
<textarea ref={taRef} value={value} onChange={e => onChange(e.target.value)} onScroll={sync} onKeyDown={onKey} spellCheck={false} autoComplete="off" rows={1}
className="absolute inset-0 w-full h-full font-mono text-xs rounded resize-none outline-none overflow-auto whitespace-pre-wrap break-all px-3 py-2.5"
style={{ background: 'transparent', color: 'transparent', caretColor: 'var(--dg-text-primary)', lineHeight: '20px', zIndex: 1 }}
placeholder={placeholder} />
<pre ref={preRef} className="absolute inset-0 m-0 font-mono text-xs rounded pointer-events-none overflow-hidden whitespace-pre-wrap break-all px-3 py-2.5" style={{ background: 'transparent', lineHeight: '20px', color: 'var(--dg-text-primary)', zIndex: 2 }} dangerouslySetInnerHTML={{ __html: html }} />
{!value && <div className="absolute inset-0 flex items-center px-3 pointer-events-none" style={{ zIndex: 3 }}><span className="text-xs font-mono" style={{ color: 'var(--dg-text-muted)' }}>{placeholder}</span></div>}
</div>
);
}
interface Props { engine: LogEngine; onRuleCreated: () => void; }
export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const { toast } = useToast();
const 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];
@@ -164,8 +84,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const [queryTime, setQueryTime] = useState(0);
const [resultTab, setResultTab] = useState<ResultTab>('logs');
const [viewMode, setViewMode] = useState<ViewMode>('raw');
const [wrapLines, setWrapLines] = useState(false);
const [truncate, setTruncate] = useState(true);
const [wrapLines, setWrapLines] = useState(true);
const [truncate, setTruncate] = useState(false);
const [selectedFields, setSelectedFields] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
@@ -176,17 +96,74 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
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[]>([]);
// 数据源加载后选中默认或首个(无源时保持空),并同步 ES 默认索引 // Logan.Li
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) {
const def = sources.find(s => s.is_default) || sources[0];
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(def.default_index || '');
setEsIndex(parsed.index || def.default_index || '');
if (parsed.query) setQuery(parsed.query);
if (parsed.timeRange) setTimeRange(parsed.timeRange);
urlBootstrapped.current = true;
return;
}
}, [sources]);
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]);
const currentSource = sources.find(s => String(s.id) === 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]);
@@ -194,55 +171,92 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
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');
let result: any;
if (effEngine === 'loki') {
result = await logCenterApi.queryLoki({
datasource_id: dsIdNum,
query,
limit: queryLimit,
start: startISO,
end: endISO,
direction: 'backward',
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,
});
} else {
result = await logCenterApi.queryElasticsearch({
datasource_id: dsIdNum,
index: esIndex || undefined,
query,
size: queryLimit,
start: startISO,
end: endISO,
});
}
const data = (result as any)?.data || result;
const rawEntries: any[] = data?.entries || [];
const mapped: LogEntry[] = rawEntries.map((e: any) => ({
timestamp: e.timestamp || '',
level: e.level || '',
message: e.message || '',
labels: e.labels || {},
raw: e.raw || e.message || '',
source: e.labels?.app || e.labels?.service_name || '',
}));
const rawEntries = data?.entries || [];
const mapped = mapRawEntries(rawEntries);
setEntries(mapped);
void fetchHistogram(dsIdNum, startISO, endISO);
setHistogramData(generateHistogramData(mapped));
setTotalCount(data?.total || mapped.length);
setTotalCount(data?.total ?? mapped.length);
setHasMore(!!data?.has_more);
setNextCursor(data?.next_cursor || '');
setSearchAfter(data?.search_after);
setQueryTime(Math.round(performance.now() - t0));
// 统计图表 / 聚类基于真实返回条目聚合(无 mock)// Logan.Li
setStatTable(buildStatTableFromEntries(mapped));
setClusters(buildClustersFromEntries(mapped));
setPage(1);
@@ -259,7 +273,139 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
} finally {
setLoading(false);
}
}, [effEngine, query, currentSource, timeRange, toast, queryLimit, esIndex]);
}, [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); };
@@ -272,6 +418,30 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
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">
{/* ===== 查询控制行 ===== */}
@@ -281,10 +451,32 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<span className="w-2 h-2 rounded-full" style={{ background: badge.dotColor }} />
<span className="text-[11px] font-semibold" style={{ color: badge.dotColor }}>{badge.label}</span>
</div>
<SyntaxHighlightInput value={query} onChange={setQuery} engine={effEngine} placeholder={meta.queryPlaceholder} />
<button onClick={handleRun} disabled={loading || !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' }}>
<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 */}
@@ -297,13 +489,10 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
</select>
{effEngine === 'elasticsearch' && (
<input
<EsIndexPicker
datasourceId={currentSource?.id}
value={esIndex}
onChange={e => setEsIndex(e.target.value)}
placeholder="索引通配符"
title="临时覆盖数据源默认索引,留空使用数据源配置"
className="h-10 px-2 text-xs rounded flex-shrink-0 font-mono"
style={{ width: '150px', background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}
onChange={setEsIndex}
/>
)}
@@ -336,6 +525,12 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
</>)}
</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>
@@ -349,11 +544,20 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
)}
{/* 快捷查询助手 */}
<QuickQueryBar engine={effEngine} templates={ALL_TEMPLATES[effEngine]} onSelect={setQuery} />
<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={() => { const t = sortedEntries.map(e => `${e.timestamp}\t${e.level || ''}\t${e.message}`).join('\n'); const b = new Blob([t], { type: 'text/plain' }); const u = URL.createObjectURL(b); const a = document.createElement('a'); a.href = u; a.download = `${effEngine}-logs-${Date.now()}.txt`; a.click(); URL.revokeObjectURL(u); }} onCopy={() => navigator.clipboard.writeText(pagedEntries.map(e => `${e.timestamp}\t${e.level || ''}\t${e.message}`).join('\n'))} onBellClick={() => setAlertDialogOpen(true)} />
<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 面积图 */}
@@ -368,8 +572,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" vertical={false} />
<XAxis dataKey="timestamp" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={{ stroke: 'var(--dg-border)' }} tickLine={false} tickFormatter={(v: string) => v.slice(-5)} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={false} tickLine={false} width={30} />
<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>
@@ -395,9 +599,9 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<div className="p-2">
{resultTab === 'logs' && (<>
<div className="flex gap-3">
<LogFieldSidebar fields={fields} selectedFields={selectedFields} onFieldClick={n => setQuery(p => `${p} ${n}=""`)} onToggleDisplayField={toggleField} loading={loading} />
<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} />
<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">
@@ -408,8 +612,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
</button>
))}
</div>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 截断</label>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 换行</label>
<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)} />}
@@ -452,37 +656,11 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
)}
{/* 对话框 */}
<SaveQueryDialog open={saveQueryOpen} onClose={() => setSaveQueryOpen(false)} engine={effEngine as LogEngine} query={query} timeRange={timeRange} datasourceName={currentSource?.name} />
<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>
);
}
// 内联紧凑版快捷查询助手
function QuickQueryBar({ engine, templates, onSelect }: { engine: 'loki' | 'elasticsearch'; templates: { label: string; query: string }[]; onSelect: (q: string) => void; }) {
const [open, setOpen] = useState(false);
const labels: Record<'loki' | 'elasticsearch', string> = { loki: 'LogQL', elasticsearch: 'Lucene' };
return (
<div>
<button onClick={() => setOpen(!open)} className="flex items-center gap-1 text-[11px] px-2 py-1 rounded transition-colors" style={{ color: 'var(--dg-blue)', background: open ? 'var(--dg-bg-tertiary)' : 'transparent' }}>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--dg-blue)' }} />
{labels[engine]} 快捷查询模板
<ChevronDown className="h-3 w-3" style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 0.2s' }} />
</button>
{open && (
<div className="mt-1.5 p-2.5 rounded-lg border" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="grid grid-cols-3 lg:grid-cols-4 gap-1.5">
{templates.map((t, i) => (
<button key={i} onClick={() => { onSelect(t.query); setOpen(false); }} className="text-left px-2 py-1.5 rounded border text-[11px] transition-all hover:opacity-80" style={{ background: 'var(--dg-bg-tertiary)', borderColor: 'var(--dg-border)', color: 'var(--dg-text-secondary)' }} title={t.query}>
<span className="font-medium block truncate" style={{ color: 'var(--dg-text-primary)' }}>{t.label}</span>
<span className="block truncate opacity-60 font-mono mt-0.5 text-[10px]">{t.query}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}