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:
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user