refactor(log-center): 清理重复/死代码组件 — 移除 DatasourceCard/DatasourceDialog/DeleteConfirmDialog/EsQueryPanel/LokiQueryPanel/LogResultsTable/LogLibraryPage/StatusDot/useLogQuery/logLibraryTypes 10 个冗余组件 (1752 行删除)

This commit is contained in:
attacker committed 2026-06-28 01:32:25 +08:00
1 parent 0c1964aa85
commit 0c2c0adaaf
13 files changed
+105 -1752

No files matched your search

@@ -1,245 +0,0 @@
// DatasourceCard.tsx — 数据源卡片组件 // Logan.Li
import { Check, X, Link, Pencil, Trash2, Loader2 } from 'lucide-react';
import { ENGINE_CONFIG } from './logLibraryTypes';
import type { LogDatasource } from './logLibraryTypes';
import { StatusDot } from './StatusDot';
interface DatasourceCardProps {
ds: LogDatasource;
onEdit: (ds: LogDatasource) => void;
onDelete: (ds: LogDatasource) => void;
onTest: (ds: LogDatasource) => void;
testingId: number | null;
testResult: { id: number; success: boolean; message: string } | null;
}
/** 单个数据源卡片:展示引擎、名称、URL、状态及操作按钮 */
export function DatasourceCard({ ds, onEdit, onDelete, onTest, testingId, testResult }: DatasourceCardProps) {
const cfg = ENGINE_CONFIG[ds.engine];
const EngineIcon = cfg.icon;
const isTesting = testingId === ds.id;
const showResult = testResult?.id === ds.id;
return (
<div
className="dg-card"
style={{
padding: '16px 18px',
display: 'flex',
flexDirection: 'column',
gap: 10,
position: 'relative',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.boxShadow = 'none';
}}
>
{/* 引擎色条 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 3,
background: cfg.color,
}}
/>
{/* 头部:图标 + 名称 + 默认标记 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, paddingTop: 4 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
background: `${cfg.color}18`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<EngineIcon size={18} style={{ color: cfg.color }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--dg-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={ds.name}
>
{ds.name}
</span>
{ds.isDefault && (
<span
style={{
fontSize: 11,
padding: '1px 6px',
borderRadius: 4,
background: 'rgba(245,158,11,0.15)',
color: 'var(--dg-amber)',
fontWeight: 500,
flexShrink: 0,
}}
>
默认
</span>
)}
</div>
<span style={{ fontSize: 12, color: 'var(--dg-text-muted)' }}>{cfg.label}</span>
</div>
</div>
{/* URL */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: 'var(--dg-text-secondary)',
overflow: 'hidden',
}}
>
<Link size={12} style={{ color: 'var(--dg-text-muted)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={ds.url}>
{ds.url}
</span>
</div>
{/* 描述 */}
{ds.description && (
<div
style={{
fontSize: 12,
color: 'var(--dg-text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{ds.description}
</div>
)}
{/* 状态行 + 操作按钮 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 2 }}>
<StatusDot status={ds.status} />
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{/* 测试连接 */}
<button
onClick={() => onTest(ds)}
disabled={isTesting}
title="测试连接"
style={{
padding: '5px 8px',
borderRadius: 5,
border: 'none',
background: 'transparent',
color: isTesting ? 'var(--dg-blue)' : 'var(--dg-text-muted)',
cursor: isTesting ? 'wait' : 'pointer',
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
onMouseEnter={(e) => {
if (!isTesting) (e.currentTarget as HTMLButtonElement).style.color = 'var(--dg-blue)';
}}
onMouseLeave={(e) => {
if (!isTesting) (e.currentTarget as HTMLButtonElement).style.color = 'var(--dg-text-muted)';
}}
>
{isTesting ? (
<Loader2 size={13} className="spin-animation" />
) : (
<Check size={13} />
)}
{isTesting ? '测试中' : '测试'}
</button>
{/* 编辑 */}
<button
onClick={() => onEdit(ds)}
title="编辑"
style={{
padding: 5,
borderRadius: 5,
border: 'none',
background: 'transparent',
color: 'var(--dg-text-muted)',
cursor: 'pointer',
display: 'flex',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = 'var(--dg-blue)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = 'var(--dg-text-muted)';
}}
>
<Pencil size={14} />
</button>
{/* 删除 */}
<button
onClick={() => onDelete(ds)}
title="删除"
style={{
padding: 5,
borderRadius: 5,
border: 'none',
background: 'transparent',
color: 'var(--dg-text-muted)',
cursor: 'pointer',
display: 'flex',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = 'var(--dg-red)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = 'var(--dg-text-muted)';
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* 测试结果提示 */}
{showResult && (
<div
style={{
marginTop: 4,
padding: '8px 10px',
borderRadius: 6,
fontSize: 12,
background: testResult.success ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)',
color: testResult.success ? 'var(--dg-green)' : 'var(--dg-red)',
border: `1px solid ${testResult.success ? 'rgba(34,197,94,0.25)' : 'rgba(239,68,68,0.25)'}`,
display: 'flex',
alignItems: 'center',
gap: 6,
animation: 'fadeIn 0.2s ease-out',
}}
>
{testResult.success ? <Check size={13} /> : <X size={13} />}
{testResult.message}
</div>
)}
</div>
);
}
@@ -1,394 +0,0 @@
// DatasourceDialog.tsx — 添加/编辑数据源对话框 // Logan.Li
import { useState, useEffect } from 'react';
import { X, Check, ChevronDown, Star } from 'lucide-react';
import { ENGINE_CONFIG } from './logLibraryTypes';
import type { LogDatasource } from './logLibraryTypes';
interface DatasourceDialogProps {
open: boolean;
editing: LogDatasource | null;
onSave: (ds: LogDatasource) => void;
onCancel: () => void;
}
/** 添加/编辑数据源对话框:引擎选择、名称、URL、描述、认证、默认设置 */
export function DatasourceDialog({ open, editing, onSave, onCancel }: DatasourceDialogProps) {
const [engine, setEngine] = useState<'loki' | 'elasticsearch'>('loki');
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [description, setDescription] = useState('');
const [isDefault, setIsDefault] = useState(false);
const [useAuth, setUseAuth] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [engineMenuOpen, setEngineMenuOpen] = useState(false);
// 初始化表单 // Logan.Li
useEffect(() => {
if (editing) {
setEngine(editing.engine);
setName(editing.name);
setUrl(editing.url);
setDescription(editing.description || '');
setIsDefault(editing.isDefault);
const has = !!editing.auth;
setUseAuth(has);
setUsername(editing.auth?.username || '');
setPassword(editing.auth?.password || '');
} else {
setEngine('loki');
setName('');
setUrl('');
setDescription('');
setIsDefault(false);
setUseAuth(false);
setUsername('');
setPassword('');
}
setEngineMenuOpen(false);
}, [editing, open]);
const handleSave = () => {
if (!name.trim() || !url.trim()) return;
onSave({
id: editing?.id || Date.now(),
engine,
name: name.trim(),
url: url.trim(),
description: description.trim() || undefined,
status: editing?.status || 'untested',
isDefault,
auth: useAuth && username.trim() ? { username: username.trim(), password: password.trim() } : null,
});
};
const isValid = name.trim() && url.trim() && (!useAuth || username.trim());
if (!open) return null;
const cfg = ENGINE_CONFIG[engine];
const EngineIcon = cfg.icon;
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.6)',
}}
onClick={onCancel}
>
<div
className="dg-card"
style={{ width: 480, padding: 0, overflow: 'hidden', animation: 'fadeIn 0.15s ease-out' }}
onClick={(e) => e.stopPropagation()}
>
{/* 标题栏 */}
<div
style={{
padding: '16px 20px',
borderBottom: '1px solid var(--dg-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--dg-text-primary)' }}>
{editing ? '编辑数据源' : '添加数据源'}
</h3>
<button
onClick={onCancel}
style={{
background: 'none',
border: 'none',
color: 'var(--dg-text-muted)',
cursor: 'pointer',
padding: 4,
display: 'flex',
}}
>
<X size={16} />
</button>
</div>
{/* 表单内容 */}
<div style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 引擎类型选择 */}
<div>
<label style={{ display: 'block', fontSize: 13, color: 'var(--dg-text-secondary)', marginBottom: 6 }}>
引擎类型 <span style={{ color: 'var(--dg-red)' }}>*</span>
</label>
<div style={{ position: 'relative' }}>
<button
onClick={() => setEngineMenuOpen(!engineMenuOpen)}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: 6,
border: '1px solid var(--dg-border)',
background: 'var(--dg-bg-tertiary)',
color: 'var(--dg-text-primary)',
fontSize: 13,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EngineIcon size={16} style={{ color: cfg.color }} />
{cfg.label}
</span>
<ChevronDown size={14} style={{ color: 'var(--dg-text-muted)' }} />
</button>
{engineMenuOpen && (
<div
className="dg-card"
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
zIndex: 10,
padding: 4,
}}
>
{Object.entries(ENGINE_CONFIG).map(([key, c]) => {
const Icon = c.icon;
return (
<button
key={key}
onClick={() => {
setEngine(key as 'loki' | 'elasticsearch');
setEngineMenuOpen(false);
}}
style={{
width: '100%',
padding: '8px 10px',
borderRadius: 4,
border: 'none',
background: engine === key ? 'var(--dg-bg-tertiary)' : 'transparent',
color: 'var(--dg-text-primary)',
fontSize: 13,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Icon size={16} style={{ color: c.color }} />
{c.label}
</button>
);
})}
</div>
)}
</div>
</div>
{/* 数据源名称 */}
<div>
<label style={{ display: 'block', fontSize: 13, color: 'var(--dg-text-secondary)', marginBottom: 6 }}>
数据源名称 <span style={{ color: 'var(--dg-red)' }}>*</span>
</label>
<input
className="dg-input"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="如:prod-loki"
style={{ width: '100%', padding: '8px 12px', borderRadius: 6, fontSize: 13 }}
/>
</div>
{/* URL */}
<div>
<label style={{ display: 'block', fontSize: 13, color: 'var(--dg-text-secondary)', marginBottom: 6 }}>
服务地址 <span style={{ color: 'var(--dg-red)' }}>*</span>
</label>
<input
className="dg-input"
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={cfg.placeholder}
style={{ width: '100%', padding: '8px 12px', borderRadius: 6, fontSize: 13 }}
/>
</div>
{/* 描述 */}
<div>
<label style={{ display: 'block', fontSize: 13, color: 'var(--dg-text-secondary)', marginBottom: 6 }}>
描述
</label>
<input
className="dg-input"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="可选,如:生产环境 Loki"
style={{ width: '100%', padding: '8px 12px', borderRadius: 6, fontSize: 13 }}
/>
</div>
{/* Basic Auth 开关 */}
<ToggleRow
label="Basic Auth 认证"
enabled={useAuth}
onToggle={() => setUseAuth(!useAuth)}
/>
{/* 认证信息 */}
{useAuth && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, paddingLeft: 8 }}>
<div>
<label style={{ display: 'block', fontSize: 13, color: 'var(--dg-text-secondary)', marginBottom: 6 }}>
用户名 <span style={{ color: 'var(--dg-red)' }}>*</span>
</label>
<input
className="dg-input"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="username"
style={{ width: '100%', padding: '8px 12px', borderRadius: 6, fontSize: 13 }}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 13, color: 'var(--dg-text-secondary)', marginBottom: 6 }}>
密码
</label>
<input
className="dg-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
style={{ width: '100%', padding: '8px 12px', borderRadius: 6, fontSize: 13 }}
/>
</div>
</div>
)}
{/* 设为默认 */}
<ToggleRow
label="设为默认数据源"
enabled={isDefault}
onToggle={() => setIsDefault(!isDefault)}
icon={<Star size={14} style={{ color: isDefault ? 'var(--dg-amber)' : 'var(--dg-text-muted)' }} />}
/>
</div>
{/* 底部按钮 */}
<div
style={{
padding: '12px 20px',
borderTop: '1px solid var(--dg-border)',
display: 'flex',
justifyContent: 'flex-end',
gap: 10,
}}
>
<button
onClick={onCancel}
style={{
padding: '7px 16px',
borderRadius: 6,
border: '1px solid var(--dg-border)',
background: 'transparent',
color: 'var(--dg-text-secondary)',
fontSize: 13,
cursor: 'pointer',
}}
>
取消
</button>
<button
onClick={handleSave}
disabled={!isValid}
style={{
padding: '7px 16px',
borderRadius: 6,
border: 'none',
background: 'var(--dg-blue)',
color: '#fff',
fontSize: 13,
cursor: isValid ? 'pointer' : 'not-allowed',
opacity: isValid ? 1 : 0.5,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<Check size={14} />
保存
</button>
</div>
</div>
</div>
);
}
// ==================== 内联子组件:开关行 ==================== // Logan.Li
function ToggleRow({
label,
enabled,
onToggle,
icon,
}: {
label: string;
enabled: boolean;
onToggle: () => void;
icon?: React.ReactNode;
}) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: 'var(--dg-bg-primary)',
borderRadius: 6,
border: '1px solid var(--dg-border)',
}}
>
<span style={{ fontSize: 13, color: 'var(--dg-text-secondary)', display: 'flex', alignItems: 'center', gap: 6 }}>
{icon}
{label}
</span>
<button
onClick={onToggle}
style={{
width: 36,
height: 20,
borderRadius: 10,
border: 'none',
background: enabled ? 'var(--dg-blue)' : 'var(--dg-bg-tertiary)',
cursor: 'pointer',
position: 'relative',
transition: 'background 0.2s',
}}
>
<span
style={{
position: 'absolute',
top: 2,
left: enabled ? 18 : 2,
width: 16,
height: 16,
borderRadius: '50%',
background: '#fff',
transition: 'left 0.2s',
}}
/>
</button>
</div>
);
}
@@ -1,113 +0,0 @@
// DeleteConfirmDialog.tsx — 删除确认对话框 // Logan.Li
import { AlertTriangle } from 'lucide-react';
import type { LogDatasource } from './logLibraryTypes';
interface DeleteConfirmDialogProps {
open: boolean;
datasource: LogDatasource | null;
hasAlerts: boolean;
onConfirm: () => void;
onCancel: () => void;
}
/** 删除数据源二次确认对话框 */
export function DeleteConfirmDialog({
open,
datasource,
hasAlerts,
onConfirm,
onCancel,
}: DeleteConfirmDialogProps) {
if (!open || !datasource) return null;
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.6)',
}}
onClick={onCancel}
>
<div
className="dg-card"
style={{ width: 420, padding: '24px 28px', animation: 'fadeIn 0.15s ease-out' }}
onClick={(e) => e.stopPropagation()}
>
{/* 标题 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<AlertTriangle size={22} style={{ color: 'var(--dg-amber)' }} />
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: 'var(--dg-text-primary)' }}>
确认删除数据源
</h3>
</div>
{/* 内容 */}
<p style={{ color: 'var(--dg-text-secondary)', fontSize: 14, lineHeight: 1.6, margin: '0 0 16px' }}>
确定要删除数据源 <strong style={{ color: 'var(--dg-text-primary)' }}>{datasource.name}</strong> 吗?
此操作不可恢复。
</p>
{hasAlerts && (
<div
style={{
background: 'rgba(245,158,11,0.1)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: 6,
padding: '10px 12px',
fontSize: 13,
color: 'var(--dg-amber)',
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<AlertTriangle size={14} />
该数据源有关联的告警规则,请先删除告警后再删除数据源。
</div>
)}
{/* 按钮 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button
onClick={onCancel}
className="dg-btn-secondary"
style={{
padding: '7px 16px',
borderRadius: 6,
border: '1px solid var(--dg-border)',
background: 'transparent',
color: 'var(--dg-text-secondary)',
fontSize: 13,
cursor: 'pointer',
}}
>
取消
</button>
<button
onClick={onConfirm}
disabled={hasAlerts}
style={{
padding: '7px 16px',
borderRadius: 6,
border: 'none',
background: hasAlerts ? 'var(--dg-bg-tertiary)' : 'var(--dg-red)',
color: '#fff',
fontSize: 13,
cursor: hasAlerts ? 'not-allowed' : 'pointer',
opacity: hasAlerts ? 0.6 : 1,
}}
>
删除
</button>
</div>
</div>
</div>
);
}
@@ -1,154 +0,0 @@
// EsQueryPanel — Elasticsearch 查询 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Play, Plus, RefreshCw, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { logCenterApi, type LogDataSource, type LogQueryEntry } from '@/api/monitor/log-center';
import { LogDatasourceDialog } from './LogDatasourceDialog';
import { LogResultsTable } from './LogResultsTable';
import { useLogQueryContext } from './LogQueryContext';
const TIME_PRESETS = [
{ label: '15 分钟', value: '15m' },
{ label: '1 小时', value: '1h' },
{ label: '6 小时', value: '6h' },
{ label: '24 小时', value: '24h' },
];
function presetToStart(preset: string): string {
const m = preset.match(/^(\d+)(m|h)$/);
if (!m) return new Date(Date.now() - 3600000).toISOString();
const n = parseInt(m[1], 10);
const ms = m[2] === 'h' ? n * 3600000 : n * 60000;
return new Date(Date.now() - ms).toISOString();
}
export function EsQueryPanel() {
const { toast } = useToast();
const { setContext } = useLogQueryContext();
const [sources, setSources] = useState<LogDataSource[]>([]);
const [dsId, setDsId] = useState<string>('');
const [index, setIndex] = useState('logstash-*');
const [query, setQuery] = useState('level:error AND message:*timeout*');
const [timePreset, setTimePreset] = useState('1h');
const [limit, setLimit] = useState(200);
const [entries, setEntries] = useState<LogQueryEntry[]>([]);
const [loading, setLoading] = useState(false);
const [dsDialog, setDsDialog] = useState(false);
const loadSources = useCallback(async () => {
const list = await logCenterApi.listDatasources('elasticsearch').catch(() => []);
const arr = Array.isArray(list) ? list : [];
setSources(arr);
if (!dsId && arr.length) {
const def = arr.find(s => s.is_default) || arr[0];
setDsId(String(def.id));
if (def.default_index) setIndex(def.default_index);
}
}, [dsId]);
useEffect(() => { loadSources(); }, [loadSources]);
const syncContext = useCallback((q: string, id: string, idx: string) => {
const ds = sources.find(s => String(s.id) === id);
setContext({
engine: 'elasticsearch',
datasourceId: ds ? ds.id : undefined,
datasourceName: ds?.name,
query: q,
index: idx,
timeRange: timePreset,
});
}, [sources, timePreset, setContext]);
useEffect(() => {
syncContext(query, dsId, index);
}, [query, dsId, index, syncContext]);
const runQuery = async () => {
if (!dsId) return;
setLoading(true);
try {
const res = await logCenterApi.queryElasticsearch({
datasource_id: Number(dsId),
index: index.trim() || undefined,
query: query.trim() || '*',
size: limit,
start: presetToStart(timePreset),
end: new Date().toISOString(),
});
setEntries(res?.entries || []);
toast({ title: '查询完成', description: `共 ${res?.total ?? 0} 条` });
} catch (e: any) {
toast({ title: '查询失败', description: e?.message || String(e), variant: 'destructive' });
setEntries([]);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[200px] flex-1">
<Label className="text-xs text-[var(--dg-text-muted)]">数据源</Label>
<div className="flex gap-2 mt-1">
<Select value={dsId} onValueChange={setDsId}>
<SelectTrigger className="h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectValue placeholder="选择 ES" />
</SelectTrigger>
<SelectContent>
{sources.map(s => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}{s.is_default ? ' (默认)' : ''}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={() => setDsDialog(true)}>
<Plus className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={loadSources}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="w-[140px]">
<Label className="text-xs text-[var(--dg-text-muted)]">索引</Label>
<Input value={index} onChange={e => setIndex(e.target.value)} className="h-9 mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<div className="w-[120px]">
<Label className="text-xs text-[var(--dg-text-muted)]">时间范围</Label>
<Select value={timePreset} onValueChange={setTimePreset}>
<SelectTrigger className="h-9 mt-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button className="h-9 bg-[var(--dg-blue)] hover:opacity-90" onClick={runQuery} disabled={loading || !dsId}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Play className="h-4 w-4 mr-1" />}
查询
</Button>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">Lucene 查询</Label>
<Input
value={query}
onChange={e => setQuery(e.target.value)}
className="mt-1 font-mono text-xs h-10 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
placeholder='status:500 AND path:"/api/*"'
/>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">支持 Lucene 语法;留空则匹配全部 · 单次最多 500 条,不落库</p>
</div>
<LogResultsTable entries={entries} loading={loading} />
<LogDatasourceDialog open={dsDialog} onOpenChange={setDsDialog} engine="elasticsearch" onSaved={loadSources} />
</div>
);
}
@@ -12,16 +12,10 @@ import { logCenterApi, type LogDataSource } from '@/api/monitor/log-center';
import { LogDatasourceDialog } from './LogDatasourceDialog';
import type { LogEngine } from './log-center-types';
// Demo 兜底数据源(API 无数据时展示) // Logan.Li
const DEMO_FALLBACK: LogDataSource[] = [
{ id: -1, name: 'demo-loki', type: 'loki', url: '' },
{ id: -2, name: 'demo-es', type: 'elasticsearch', url: '' },
];
export function LogDatasourcePage() {
const { toast } = useToast();
const confirm = useConfirm();
const [sources, setSources] = useState<LogDataSource[]>(DEMO_FALLBACK);
const [sources, setSources] = useState<LogDataSource[]>([]);
const [loading, setLoading] = useState(true);
const [dialog, setDialog] = useState<{ open: boolean; engine: LogEngine; editing?: LogDataSource | null }>({
open: false, engine: 'loki', editing: null,
@@ -32,10 +26,9 @@ export function LogDatasourcePage() {
try {
const res = await logCenterApi.listDatasources();
const apiList: LogDataSource[] = Array.isArray(res) ? res : [];
// 真实数据源在前 + demo 兜底在后 // Logan.Li
setSources([...apiList, ...DEMO_FALLBACK]);
setSources(apiList);
} catch {
setSources(DEMO_FALLBACK);
setSources([]);
} finally {
setLoading(false);
}
@@ -116,31 +109,28 @@ export function LogDatasourcePage() {
},
{
key: 'actions', header: '操作', align: 'right', hideable: false,
render: (ds) => {
const isDemo = ds.id < 0 || !ds.url;
return (
render: (ds) => (
<div className="flex items-center justify-end gap-0.5">
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
title={isDemo ? '模拟数据不可测试' : '测试连接'} onClick={() => !isDemo && handleTest(ds)} disabled={isDemo}>
title="测试连接" onClick={() => handleTest(ds)}>
<Plug className="h-3.5 w-3.5" />
</Button>
{!ds.is_default && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-amber-400"
title={isDemo ? '模拟数据不可设置' : '设为默认'} onClick={() => !isDemo && handleSetDefault(ds)} disabled={isDemo}>
title="设为默认" onClick={() => handleSetDefault(ds)}>
<Star className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
title={isDemo ? '模拟数据不可编辑' : '编辑'} onClick={() => !isDemo && handleEdit(ds)} disabled={isDemo}>
title="编辑" onClick={() => handleEdit(ds)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-red-400"
title={isDemo ? '模拟数据不可删除' : '删除'} onClick={() => !isDemo && handleDelete(ds)} disabled={isDemo}>
title="删除" onClick={() => handleDelete(ds)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
},
),
},
];
@@ -1,269 +0,0 @@
// LogLibraryPage.tsx — 日志库(数据源管理)主页面 // Logan.Li
// 组装卡片列表、编辑对话框、删除确认对话框,管理全局状态
import { useState, useEffect, useCallback } from 'react';
import { Database, Flame, Plus, Check, Server } from 'lucide-react';
import { loadDatasources, saveDatasources, ENGINE_CONFIG } from './logLibraryTypes';
import type { LogDatasource } from './logLibraryTypes';
import { DatasourceCard } from './DatasourceCard';
import { DatasourceDialog } from './DatasourceDialog';
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
export function LogLibraryPage() {
const [datasources, setDatasources] = useState<LogDatasource[]>(loadDatasources);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LogDatasource | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState<LogDatasource | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
const [testResult, setTestResult] = useState<{ id: number; success: boolean; message: string } | null>(null);
// 数据变更时持久化到 localStorage // Logan.Li
useEffect(() => {
saveDatasources(datasources);
}, [datasources]);
// 统计数据 // Logan.Li
const lokiCount = datasources.filter((d) => d.engine === 'loki').length;
const esCount = datasources.filter((d) => d.engine === 'elasticsearch').length;
const connectedCount = datasources.filter((d) => d.status === 'connected').length;
// 打开添加对话框 // Logan.Li
const openCreate = useCallback(() => {
setEditing(null);
setDialogOpen(true);
}, []);
// 打开编辑对话框 // Logan.Li
const openEdit = useCallback((ds: LogDatasource) => {
setEditing(ds);
setDialogOpen(true);
}, []);
// 保存数据源(新增或更新) // Logan.Li
const handleSave = useCallback((ds: LogDatasource) => {
setDatasources((prev) => {
// 若设为默认,取消同引擎其他数据源的默认标记 // Logan.Li
let next = prev.map((item) =>
ds.isDefault && item.engine === ds.engine && item.id !== ds.id
? { ...item, isDefault: false }
: item
);
const exists = next.find((item) => item.id === ds.id);
if (exists) {
next = next.map((item) => (item.id === ds.id ? ds : item));
} else {
next = [...next, ds];
}
return next;
});
setDialogOpen(false);
}, []);
// 打开删除确认 // Logan.Li
const openDelete = useCallback((ds: LogDatasource) => {
setDeleting(ds);
setDeleteOpen(true);
}, []);
// 确认删除 // Logan.Li
const confirmDelete = useCallback(() => {
if (!deleting) return;
setDatasources((prev) => prev.filter((d) => d.id !== deleting.id));
setDeleteOpen(false);
setDeleting(null);
}, [deleting]);
// 测试连接 // Logan.Li
const handleTest = useCallback((ds: LogDatasource) => {
setTestingId(ds.id);
setTestResult(null);
// 模拟异步测试 // Logan.Li
setTimeout(() => {
const success = ds.status !== 'error';
const streamCount = Math.floor(Math.random() * 50) + 10;
setTestResult({
id: ds.id,
success,
message: success
? `连接成功,${streamCount} 个日志流`
: '连接失败:无法访问目标地址 (timeout)',
});
// 同步更新数据源状态 // Logan.Li
setDatasources((prev) =>
prev.map((d) =>
d.id === ds.id ? { ...d, status: success ? 'connected' : 'error' } : d
)
);
setTestingId(null);
}, 1200);
}, []);
// 检查数据源是否有关联告警 // Logan.Li
const hasRelatedAlerts = useCallback((ds: LogDatasource): boolean => {
try {
const alertsRaw = localStorage.getItem('log-center:alerts');
if (alertsRaw) {
const alerts = JSON.parse(alertsRaw);
return alerts.some((a: { datasourceId?: number; datasourceName?: string }) =>
a.datasourceId === ds.id || a.datasourceName === ds.name
);
}
} catch { /* ignore */ }
return false;
}, []);
return (
<div style={{ padding: 20, maxWidth: 1200, margin: '0 auto' }}>
{/* 页面标题 + 统计 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 20,
flexWrap: 'wrap',
gap: 12,
}}
>
<div>
<h1
style={{
margin: '0 0 6px',
fontSize: 20,
fontWeight: 700,
color: 'var(--dg-text-primary)',
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
<Server size={22} style={{ color: 'var(--dg-blue)' }} />
日志库
</h1>
<p style={{ margin: 0, fontSize: 13, color: 'var(--dg-text-muted)' }}>
管理 Loki 和 Elasticsearch 数据源配置
</p>
</div>
{/* 统计 + 添加按钮 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<StatBadge icon={<Flame size={15} color={ENGINE_CONFIG.loki.color} />} label="Loki" value={lokiCount} valueColor={ENGINE_CONFIG.loki.color} />
<StatBadge icon={<Database size={15} color={ENGINE_CONFIG.elasticsearch.color} />} label="ES" value={esCount} valueColor={ENGINE_CONFIG.elasticsearch.color} />
<StatBadge icon={<Check size={15} color="#22c55e" />} label="正常" value={connectedCount} valueColor="var(--dg-green)" />
<button
onClick={openCreate}
style={{
padding: '8px 18px',
borderRadius: 6,
border: 'none',
background: 'var(--dg-blue)',
color: '#fff',
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 6,
transition: 'opacity 0.2s',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.opacity = '0.85'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.opacity = '1'; }}
>
<Plus size={15} />
添加数据源
</button>
</div>
</div>
{/* 分隔线 */}
<div style={{ height: 1, background: 'var(--dg-border)', marginBottom: 20 }} />
{/* 数据源卡片网格 */}
{datasources.length === 0 ? (
<EmptyState />
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: 14,
}}
>
{datasources.map((ds) => (
<DatasourceCard
key={ds.id}
ds={ds}
onEdit={openEdit}
onDelete={openDelete}
onTest={handleTest}
testingId={testingId}
testResult={testResult}
/>
))}
</div>
)}
{/* 子对话框 */}
<DatasourceDialog open={dialogOpen} editing={editing} onSave={handleSave} onCancel={() => setDialogOpen(false)} />
<DeleteConfirmDialog
open={deleteOpen}
datasource={deleting}
hasAlerts={deleting ? hasRelatedAlerts(deleting) : false}
onConfirm={confirmDelete}
onCancel={() => { setDeleteOpen(false); setDeleting(null); }}
/>
</div>
);
}
// ==================== 内联子组件:统计徽章 ==================== // Logan.Li
function StatBadge({
icon,
label,
value,
valueColor,
}: {
icon: React.ReactNode;
label: string;
value: number;
valueColor: string;
}) {
return (
<div
className="dg-card"
style={{
padding: '8px 16px',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
}}
>
{icon}
<span style={{ color: 'var(--dg-text-muted)' }}>{label}</span>
<span style={{ color: valueColor, fontWeight: 700, fontSize: 16 }}>{value}</span>
</div>
);
}
// ==================== 内联子组件:空状态 ==================== // Logan.Li
function EmptyState() {
return (
<div
style={{
textAlign: 'center',
padding: '60px 20px',
color: 'var(--dg-text-muted)',
}}
>
<Database size={40} style={{ marginBottom: 12, opacity: 0.4 }} />
<p style={{ fontSize: 14, margin: '0 0 8px' }}>暂无数据源</p>
<p style={{ fontSize: 12, margin: 0 }}>点击右上角「添加数据源」按钮创建</p>
</div>
);
}
@@ -1,42 +0,0 @@
// LogResultsTable — 日志查询结果表格 // Logan.Li
import { Badge } from '@/components/ui/badge';
import type { LogQueryEntry } from '@/api/monitor/log-center';
const levelColor: Record<string, string> = {
error: 'bg-red-500/15 text-red-400 border-red-500/30',
warn: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
warning: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
info: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
debug: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
};
export function LogResultsTable({ entries, loading }: { entries: LogQueryEntry[]; loading?: boolean }) {
if (loading) {
return <div className="text-center py-16 text-[var(--dg-text-muted)] text-sm">查询中...</div>;
}
if (entries.length === 0) {
return <div className="text-center py-16 text-[var(--dg-text-muted)] text-sm">暂无日志,调整查询条件后重试</div>;
}
return (
<div className="border border-[var(--dg-border)] rounded-lg overflow-hidden">
<div className="grid grid-cols-[160px_72px_1fr] gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)]/50 text-[11px] font-medium text-[var(--dg-text-muted)] uppercase">
<span>时间</span>
<span>级别</span>
<span>内容</span>
</div>
<div className="max-h-[480px] overflow-y-auto divide-y divide-[var(--dg-border)]">
{entries.map((e, i) => (
<div key={i} className="grid grid-cols-[160px_72px_1fr] gap-2 px-3 py-2 text-xs hover:bg-[var(--dg-bg-tertiary)]/30">
<span className="font-mono text-[var(--dg-text-muted)] truncate" title={e.timestamp}>{e.timestamp}</span>
<span>
{e.level ? (
<Badge variant="outline" className={`text-[10px] ${levelColor[e.level.toLowerCase()] || 'border-[var(--dg-border)]'}`}>{e.level}</Badge>
) : '—'}
</span>
<pre className="font-mono text-[var(--dg-text-secondary)] whitespace-pre-wrap break-all leading-relaxed">{e.message}</pre>
</div>
))}
</div>
</div>
);
}
@@ -1,154 +0,0 @@
// LokiQueryPanel — LogQL 查询 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Play, Plus, RefreshCw, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { logCenterApi, type LogDataSource, type LogQueryEntry } from '@/api/monitor/log-center';
import { LogDatasourceDialog } from './LogDatasourceDialog';
import { LogResultsTable } from './LogResultsTable';
import { useLogQueryContext } from './LogQueryContext';
const TIME_PRESETS = [
{ label: '15 分钟', value: '15m' },
{ label: '1 小时', value: '1h' },
{ label: '6 小时', value: '6h' },
{ label: '24 小时', value: '24h' },
];
function presetToStart(preset: string): string {
const m = preset.match(/^(\d+)(m|h)$/);
if (!m) return new Date(Date.now() - 3600000).toISOString();
const n = parseInt(m[1], 10);
const ms = m[2] === 'h' ? n * 3600000 : n * 60000;
return new Date(Date.now() - ms).toISOString();
}
export function LokiQueryPanel() {
const { toast } = useToast();
const { setContext } = useLogQueryContext();
const [sources, setSources] = useState<LogDataSource[]>([]);
const [dsId, setDsId] = useState<string>('');
const [query, setQuery] = useState('{namespace="default"} |= "error"');
const [timePreset, setTimePreset] = useState('1h');
const [limit, setLimit] = useState(200);
const [entries, setEntries] = useState<LogQueryEntry[]>([]);
const [loading, setLoading] = useState(false);
const [dsDialog, setDsDialog] = useState(false);
const loadSources = useCallback(async () => {
const list = await logCenterApi.listDatasources('loki').catch(() => []);
const arr = Array.isArray(list) ? list : [];
setSources(arr);
if (!dsId && arr.length) {
const def = arr.find(s => s.is_default) || arr[0];
setDsId(String(def.id));
}
}, [dsId]);
useEffect(() => { loadSources(); }, [loadSources]);
const syncContext = useCallback((q: string, id: string) => {
const ds = sources.find(s => String(s.id) === id);
setContext({
engine: 'loki',
datasourceId: ds ? ds.id : undefined,
datasourceName: ds?.name,
query: q,
timeRange: timePreset,
});
}, [sources, timePreset, setContext]);
useEffect(() => {
syncContext(query, dsId);
}, [query, dsId, syncContext]);
const runQuery = async () => {
if (!dsId || !query.trim()) return;
setLoading(true);
try {
const res = await logCenterApi.queryLoki({
datasource_id: Number(dsId),
query: query.trim(),
limit,
start: presetToStart(timePreset),
end: new Date().toISOString(),
});
setEntries(res?.entries || []);
toast({ title: '查询完成', description: `共 ${res?.total ?? 0} 条` });
} catch (e: any) {
toast({ title: '查询失败', description: e?.message || String(e), variant: 'destructive' });
setEntries([]);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[200px] flex-1">
<Label className="text-xs text-[var(--dg-text-muted)]">数据源</Label>
<div className="flex gap-2 mt-1">
<Select value={dsId} onValueChange={setDsId}>
<SelectTrigger className="h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectValue placeholder="选择 Loki" />
</SelectTrigger>
<SelectContent>
{sources.map(s => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}{s.is_default ? ' (默认)' : ''}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={() => setDsDialog(true)} title="添加数据源">
<Plus className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={loadSources} title="刷新">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="w-[120px]">
<Label className="text-xs text-[var(--dg-text-muted)]">时间范围</Label>
<Select value={timePreset} onValueChange={setTimePreset}>
<SelectTrigger className="h-9 mt-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="w-[88px]">
<Label className="text-xs text-[var(--dg-text-muted)]">条数</Label>
<Input type="number" value={limit} onChange={e => setLimit(Number(e.target.value) || 200)} className="h-9 mt-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<Button className="h-9 bg-[var(--dg-blue)] hover:opacity-90" onClick={runQuery} disabled={loading || !dsId}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Play className="h-4 w-4 mr-1" />}
查询
</Button>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">LogQL</Label>
<Textarea
value={query}
onChange={e => setQuery(e.target.value)}
rows={3}
className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] resize-y"
placeholder='{app="nginx"} |= "error"'
/>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
示例:{'{job="varlogs"}'} | json | level="error" · 单次最多 500 条,不落库
</p>
</div>
<LogResultsTable entries={entries} loading={loading} />
<LogDatasourceDialog open={dsDialog} onOpenChange={setDsDialog} engine="loki" onSaved={loadSources} />
</div>
);
}
@@ -1,27 +0,0 @@
// StatusDot.tsx — 状态指示器组件 // Logan.Li
import { STATUS_CONFIG } from './logLibraryTypes';
interface StatusDotProps {
status: string;
}
/** 数据源状态指示器:彩色圆点 + 文字 */
export function StatusDot({ status }: StatusDotProps) {
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.untested;
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: cfg.dot,
boxShadow: `0 0 6px ${cfg.dot}66`,
display: 'inline-block',
}}
/>
<span style={{ color: cfg.color, fontSize: 13 }}>{cfg.label}</span>
</span>
);
}
@@ -13,7 +13,7 @@ import { QueryHistoryPanel } from './QueryHistoryPanel';
import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog';
import { logCenterApi, type LogDataSource } from '@/api/monitor/log-center';
import type { LogEngine, TimeRange, ResultTab, ViewMode, LogEntry, StatTable, LogCluster, QueryHistoryItem } from './types-v3';
import { ENGINE_META, generateMockEntries, generateMockStatTable, generateMockClusters, extractFields, generateHistogramData, historyStorage } from './types-v3';
import { ENGINE_META, buildStatTableFromEntries, buildClustersFromEntries, extractFields, generateHistogramData, historyStorage } from './types-v3';
import { useToast } from '@/hooks/use-toast';
// 默认最近5分钟 // Logan.Li
@@ -24,12 +24,6 @@ const DEFAULT_TIME_RANGE: TimeRange = {
type: 'relative',
};
// Demo 数据源(后端无数据源时的兜底)// Logan.Li
const DEMO_SOURCES: Record<'loki' | 'elasticsearch', LogDataSource[]> = {
loki: [{ id: -1, name: 'demo-loki', type: 'loki', url: '' }],
elasticsearch: [{ id: -2, name: 'demo-es', type: 'elasticsearch', url: '' }],
};
// 引擎默认查询语句 // Logan.Li
const DEFAULT_QUERIES: Record<'loki' | 'elasticsearch', string> = {
loki: '{app="cex-admin-api"} |= ""',
@@ -81,22 +75,34 @@ const ALL_TEMPLATES: Record<'loki' | 'elasticsearch', { label: string; query: st
// 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') {
return query
.replace(/([a-zA-Z_][\w-]*)(?==)/g, '<s c="#f59e0b">$1</s>')
.replace(/("(?:[^"\\]|\\.)*")/g, '<s c="#22c55e">$1</s>')
.replace(/(\|=|\!=|=~|!~|=)/g, '<s c="#f43f5e">$1</s>')
.replace(/(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)/g, '<s c="#8b5cf6">$1</s>')
.replace(/\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b/g, '<s c="#06b6d4">$1</s>')
.replace(/(\{|\})/g, '<s c="#f59e0b">$1</s>')
.replace(/<s c=/g, '<span style="color=') + '<span style="color:transparent">.</span>';
// 单正则 + 回调:一次匹配,避免链式 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 query
.replace(/\b(AND|OR|NOT)\b/g, '<s c="#f43f5e">$1</s>')
.replace(/([a-zA-Z_@][\w.-]*):/g, '<s c="#f59e0b">$1</s>:')
.replace(/("(?:[^"\\]|\\.)*")/g, '<s c="#22c55e">$1</s>')
.replace(/(\*|\?|\~|\^|\[|\]|\(|\))/g, '<s c="#8b5cf6">$1</s>')
.replace(/<s c=/g, '<span style="color=') + '<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
@@ -132,15 +138,13 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const meta = ENGINE_META[effEngine];
const badge = ENGINE_BADGE[effEngine];
// 从后端加载真实数据源,同时保留 demo 兜底 // Logan.Li
const [sources, setSources] = useState<LogDataSource[]>(DEMO_SOURCES[effEngine]);
// 从后端加载真实数据源(无 demo 兜底,无源时引导用户前往日志库配置)// Logan.Li
const [sources, setSources] = useState<LogDataSource[]>([]);
useEffect(() => {
logCenterApi.listDatasources(effEngine).then((res: any) => {
const list: LogDataSource[] = Array.isArray(res) ? res : (res?.list || []);
// 合并真实数据源 + demo 兜底(真实源在前) // Logan.Li
if (list.length > 0) setSources([...list, ...DEMO_SOURCES[effEngine]]);
else setSources(DEMO_SOURCES[effEngine]);
}).catch(() => setSources(DEMO_SOURCES[effEngine]));
setSources(list);
}).catch(() => setSources([]));
}, [effEngine]);
const [query, setQuery] = useState(DEFAULT_QUERIES[effEngine]);
@@ -168,13 +172,15 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const [saveMenuOpen, setSaveMenuOpen] = useState(false);
const [hasQueried, setHasQueried] = useState(false);
// 数据源切换后更新 dsId // Logan.Li
// 数据源加载后选中默认或首个(无源时保持空)// Logan.Li
useEffect(() => {
if (sources.length > 0) setDsId(String(sources[0].id));
if (sources.length > 0) {
const def = sources.find(s => s.is_default) || sources[0];
setDsId(String(def.id));
}
}, [sources]);
const currentSource = sources.find(s => String(s.id) === dsId);
const isDemo = currentSource && (currentSource.id < 0 || !currentSource.url);
const fields = useMemo(() => extractFields(entries), [entries]);
const commonLabels = useMemo(() => extractCommonLabels(entries), [entries]);
@@ -182,26 +188,13 @@ 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]);
// 执行查询 — 接入真实 API(demo 源则使用 mock)// Logan.Li
// 执行查询 — 接入真实 API(无 mock)// Logan.Li
const handleRun = useCallback(async () => {
if (!currentSource) return;
const dsIdNum = currentSource.id;
setLoading(true);
const t0 = performance.now();
// Demo 源:模拟数据
if (isDemo) {
setTimeout(() => {
const mock = generateMockEntries(effEngine as LogEngine, 120);
setEntries(mock); setHistogramData(generateHistogramData(mock)); setTotalCount(mock.length * 14);
setQueryTime(Math.round(performance.now() - t0)); setStatTable(generateMockStatTable(effEngine as LogEngine));
setClusters(generateMockClusters()); setPage(1); setLoading(false); setHasQueried(true);
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mock.length });
}, 300);
return;
}
// 真实 API 查询
try {
const startISO = (timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z')).replace(' ', 'T');
const endISO = (timeRange.end + (timeRange.end.includes('T') ? '' : '.000Z')).replace(' ', 'T');
@@ -241,8 +234,9 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
setHistogramData(generateHistogramData(mapped));
setTotalCount(data?.total || mapped.length);
setQueryTime(Math.round(performance.now() - t0));
setStatTable(generateMockStatTable(effEngine as LogEngine));
setClusters(generateMockClusters());
// 统计图表 / 聚类基于真实返回条目聚合(无 mock)// Logan.Li
setStatTable(buildStatTableFromEntries(mapped));
setClusters(buildClustersFromEntries(mapped));
setPage(1);
setHasQueried(true);
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mapped.length });
@@ -252,7 +246,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
} finally {
setLoading(false);
}
}, [effEngine, query, currentSource, timeRange, isDemo, toast]);
}, [effEngine, query, currentSource, timeRange, toast]);
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); };
@@ -280,10 +274,11 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<TimeRangePicker value={timeRange} onChange={setTimeRange} />
<select value={dsId} onChange={e => setDsId(e.target.value)} className="h-10 px-2 text-xs rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{(s.id < 0 || !s.url) ? ' (模拟)' : ''}</option>)}
{sources.length === 0 && <option value="">无可用{meta.name}数据源</option>}
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? ' (默认)' : ''}</option>)}
</select>
<button onClick={handleRun} disabled={loading} 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" style={{ background: '#22c55e' }}>
<button onClick={handleRun} disabled={loading || !currentSource} 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" style={{ background: '#22c55e' }}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
</button>
@@ -305,6 +300,13 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
</button>
</div>
{/* 无数据源时引导用户前往日志库配置 // Logan.Li */}
{sources.length === 0 && (
<div className="p-3 rounded border text-xs" style={{ background: 'rgba(245,158,11,0.1)', borderColor: 'rgba(245,158,11,0.3)', color: '#f59e0b' }}>
暂无{meta.name}数据源,请先前往「日志库」添加数据源。
</div>
)}
{/* 快捷查询助手 */}
<QuickQueryBar engine={effEngine} templates={ALL_TEMPLATES[effEngine]} onSelect={setQuery} />
@@ -1,90 +0,0 @@
// logLibraryTypes.ts — 日志库(数据源管理)类型定义与常量 // Logan.Li
import type { LogEngine } from './types-v3';
import { Flame, Database } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
/** 数据源认证信息 */
export interface DatasourceAuth {
username: string;
password: string;
}
/** 日志数据源定义 */
export interface LogDatasource {
id: number;
engine: 'loki' | 'elasticsearch';
name: string;
url: string;
description?: string;
status: 'connected' | 'error' | 'untested';
isDefault: boolean;
auth?: DatasourceAuth | null;
}
/** 引擎配置信息 */
export interface EngineConfigItem {
icon: LucideIcon;
color: string;
label: string;
placeholder: string;
}
/** 状态显示配置 */
export interface StatusConfigItem {
color: string;
label: string;
dot: string;
}
/** localStorage 存储键 */
export const STORAGE_KEY = 'log-center:datasources';
/** 引擎配置映射 */
export const ENGINE_CONFIG: Record<string, EngineConfigItem> = {
loki: { icon: Flame, color: '#06b6d4', label: 'Loki', placeholder: 'http://loki.host:3100' },
elasticsearch: { icon: Database, color: '#22c55e', label: 'Elasticsearch', placeholder: 'http://es.host:9200' },
};
/** 状态显示配置映射 */
export const STATUS_CONFIG: Record<string, StatusConfigItem> = {
connected: { color: 'var(--dg-green)', label: '正常', dot: '#22c55e' },
error: { color: 'var(--dg-red)', label: '异常', dot: '#ef4444' },
untested: { color: 'var(--dg-text-muted)', label: '未测试', dot: '#94a3b8' },
};
/** Mock 数据源(仅演示用 — demo-loki / demo-es 各一个) */
export const MOCK_DATASOURCES: LogDatasource[] = [
{ id: 1, engine: 'loki', name: 'demo-loki', url: '', status: 'connected', description: 'Loki 模拟数据', isDefault: true, auth: null },
{ id: 2, engine: 'elasticsearch', name: 'demo-es', url: '', status: 'connected', description: 'ES 模拟数据', isDefault: true, auth: null },
];
/** 从 localStorage 加载数据源(迁移旧数据自动清理) */
export function loadDatasources(): LogDatasource[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const list: LogDatasource[] = JSON.parse(raw);
// 版本迁移:旧数据(含 prod-loki / dev-loki 等)强制重置为 demo
const oldNames = list.map((d) => d.name);
if (oldNames.includes('prod-loki') || oldNames.includes('dev-es') || oldNames.includes('prod-es') || oldNames.includes('dev-loki')) {
localStorage.removeItem(STORAGE_KEY);
return [...MOCK_DATASOURCES];
}
return list;
}
} catch { /* ignore */ }
return [...MOCK_DATASOURCES];
}
/** 保存数据源到 localStorage */
export function saveDatasources(list: LogDatasource[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
}
/** 引擎图标映射(兼容 types-v3 的 LogEngine) */
export const ENGINE_ICONS: Record<LogEngine, { Icon: LucideIcon; color: string; name: string }> = {
loki: { Icon: Flame, color: '#06b6d4', name: 'Loki' },
elasticsearch: { Icon: Database, color: '#22c55e', name: 'ES' },
sls: { Icon: Database, color: '#f97316', name: 'SLS' },
};
@@ -136,83 +136,55 @@ export const ENGINE_META: Record<LogEngine, { name: string; color: string; query
},
};
// Mock 数据生成 — Java/Spring Boot 风格 // Logan.Li
export function generateMockEntries(engine: LogEngine, count: number = 100): LogEntry[] {
const entries: LogEntry[] = [];
const now = Date.now();
const LEVELS = ['INFO', 'WARN', 'ERROR', 'DEBUG'];
const APP = 'cex-admin-api';
const THREADS = ['XNIO-1 task-2', 'XNIO-1 task-1', 'XNIO-1 I/O-1'];
const CLASSES = [
'c.c.a.api.service.asset.AssetService',
'c.c.a.api.gateway.FuturesServiceGrpcClient',
'c.c.a.api.controller.OrderController',
'c.c.a.api.service.auth.AuthService',
];
for (let i = 0; i < count; i++) {
const ts = new Date(now - Math.random() * 86400000);
const level = LEVELS[Math.floor(Math.random() * LEVELS.length)];
const thread = THREADS[Math.floor(Math.random() * THREADS.length)];
const cls = CLASSES[Math.floor(Math.random() * CLASSES.length)];
const orgId = 10001 + Math.floor(Math.random() * 5);
const brokerUserId = 97164000 + Math.floor(Math.random() * 1000);
const labels: Record<string, string> = {
app: APP,
container: APP,
detected_level: level.toLowerCase(),
namespace: 'astralx',
pod: 'cex-admin-api-66c5cc7d6c-wkngz',
service_name: APP,
stream: 'stdout',
level: level.toLowerCase(),
node_name: 'ip-172-36-156-240.ap-northeast-1.compute.internal',
};
const tsISO = ts.toISOString();
const message = `${tsISO} ${level} 1 --- [${APP}] [${thread}] ${cls} : admin ${['getBalanceFlows', 'getBrokerUser', 'getOrderList', 'authenticate'][Math.floor(Math.random() * 4)]} ${level === 'ERROR' ? 'failed' : level === 'WARN' ? 'resolved' : 'response'}. orgId=${orgId}, brokerUserId=${brokerUserId}`;
const raw = JSON.stringify({ ts: tsISO, level: level.toLowerCase(), logger: cls, thread, message: message.slice(0, 80), app: APP, orgId, brokerUserId });
entries.push({
timestamp: ts.toISOString().replace('T', ' ').substring(0, 19),
level: level.toLowerCase(),
message,
labels,
raw: engine === 'loki' ? message : raw,
source: APP,
});
}
return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}
// 生成统计表格 Mock // Logan.Li
export function generateMockStatTable(engine: LogEngine): StatTable {
const methods = ['GET', 'POST', 'PUT', 'DELETE'];
const levels = ['info', 'warn', 'error'];
if (engine === 'loki') {
return {
columns: [{ name: 'level', type: 'string' }, { name: 'count', type: 'number' }],
rows: levels.map(l => ({ level: l, count: Math.floor(Math.random() * 5000 + 1000) })),
};
}
// 真实统计聚合 — 基于查询返回的真实条目按级别计数(无 mock)// Logan.Li
// level 由后端 flattenLokiStreams / flattenESEntries 提取;缺失归 unknown
export function buildStatTableFromEntries(entries: LogEntry[]): StatTable {
const counter = new Map<string, number>();
entries.forEach(e => {
const key = (e.level || e.labels?.level || 'unknown').toString();
counter.set(key, (counter.get(key) || 0) + 1);
});
const rows = Array.from(counter.entries())
.map(([level, count]) => ({ level, count }))
.sort((a, b) => b.count - a.count);
return {
columns: [{ name: 'method', type: 'string' }, { name: 'count', type: 'number' }],
rows: methods.map(m => ({ method: m, count: Math.floor(Math.random() * 2000 + 100) })),
columns: [
{ name: 'level', type: 'string' },
{ name: 'count', type: 'number' },
],
rows,
};
}
// 生成聚类 Mock // Logan.Li
export function generateMockClusters(): LogCluster[] {
return [
{ pattern: 'admin getBalanceFlows response. orgId=*, brokerUserId=*', count: 4523, sample: 'admin getBalanceFlows response. orgId=10001, brokerUserId=97164001', percentage: 37.7 },
{ pattern: 'admin getBrokerUser resolved. orgId=*, brokerUserId=*', count: 3281, sample: 'admin getBrokerUser resolved. orgId=10002, brokerUserId=97164234', percentage: 27.3 },
{ pattern: 'admin authenticate response. orgId=*, brokerUserId=*', count: 2156, sample: 'admin authenticate response. orgId=10003, brokerUserId=97164567', percentage: 18.0 },
{ pattern: 'admin getOrderList failed. orgId=*, brokerUserId=*', count: 1024, sample: 'admin getOrderList failed. orgId=10004, brokerUserId=97164890', percentage: 8.5 },
{ pattern: 'admin updateBalance response. orgId=*, brokerUserId=*', count: 890, sample: 'admin updateBalance response. orgId=10001, brokerUserId=97164123', percentage: 7.4 },
{ pattern: 'Other patterns', count: 126, sample: '...', percentage: 1.1 },
];
// 真实日志聚类 — 将 message 中数字/十六进制/IPv4/UUID 泛化为 * 后分组计数 // Logan.Li
export function buildClustersFromEntries(entries: LogEntry[]): LogCluster[] {
const buckets = new Map<string, { count: number; sample: string }>();
const generalize = (msg: string): string => {
if (!msg) return '';
return msg
.replace(/\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g, '*') // UUID(优先于一般十六进制)
.replace(/\b\d{1,3}(\.\d{1,3}){3}\b/g, '*') // IPv4
.replace(/[0-9a-fA-F]{8,}/g, '*') // 长十六进制串(traceId / objectId 等)
.replace(/\d+/g, '*'); // 连续数字
};
entries.forEach(e => {
const msg = e.message || '';
if (!msg) return;
const pattern = generalize(msg);
const hit = buckets.get(pattern);
if (hit) hit.count += 1;
else buckets.set(pattern, { count: 1, sample: msg });
});
const total = entries.length || 1;
return Array.from(buckets.entries())
.map(([pattern, v]) => ({
pattern,
count: v.count,
sample: v.sample,
percentage: Math.round((v.count / total) * 1000) / 10,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
}
export function extractFields(entries: LogEntry[]): LogField[] {
@@ -273,17 +245,6 @@ export const savedQueryStorage = {
},
remove(id: string) { localStorage.setItem(SAVED_QUERY_KEY, JSON.stringify(this.getAll().filter(q => q.id !== id))); },
};
// Mock 告警规则 // Logan.Li
export function getMockAlerts(): AlertRule[] {
return [
{ id: 1, name: 'Nginx 5xx 错误告警', expression: '{job="nginx"} |= "error" | json | status_code>=500', engine: 'loki', severity: 'critical', enabled: true, description: 'Nginx 5xx 错误数 > 0 触发告警' },
{ id: 2, name: 'API 慢响应检测', expression: 'response_time:>2000 AND path:"/api/*"', engine: 'elasticsearch', severity: 'warning', enabled: true, description: 'API 响应时间超过 2 秒' },
{ id: 3, name: '错误日志频率', expression: "* | select count(*) as cnt where level = 'error' having cnt > 100", engine: 'sls', severity: 'warning', enabled: false, description: '1 小时内错误日志超过 100 条' },
{ id: 4, name: '服务离线检测', expression: '{job="agent"} |= "offline"', engine: 'loki', severity: 'critical', enabled: true, description: 'Agent 离线检测' },
];
}
// 快捷查询模板 // Logan.Li
export const QUICK_TEMPLATES: Record<LogEngine, { label: string; query: string }[]> = {
loki: [
@@ -1,112 +0,0 @@
// useLogQuery.ts — 日志查询通用 Hook // Logan.Li
import { useState, useMemo, useCallback } from 'react';
import type { LogEntry, ViewMode } from './types-v3';
import { generateMockEntries, generateHistogramData, extractFields, historyStorage } from './types-v3';
import type { LogEngine } from './types-v3';
export interface UseLogQueryOptions {
engine: LogEngine;
defaultQuery: string;
}
export function useLogQuery({ engine, defaultQuery }: UseLogQueryOptions) {
const [query, setQuery] = useState(defaultQuery);
const [allEntries, setAllEntries] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const [queryTime, setQueryTime] = useState(0);
const [queryStatus, setQueryStatus] = useState<'precise' | 'partial' | 'failed'>('precise');
// 视图状态 // Logan.Li
const [viewMode, setViewMode] = useState<ViewMode>('raw');
const [wrapLines, setWrapLines] = useState(false);
const [truncate, setTruncate] = useState(true);
const [selectedFields, setSelectedFields] = useState<string[]>(['timestamp', 'level', 'service', 'message']);
// 分页 // Logan.Li
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
// 对话框 // Logan.Li
const [alertDialog, setAlertDialog] = useState(false);
const [historyPanel, setHistoryPanel] = useState(false);
// 排序后的 entries // Logan.Li
const sortedEntries = useMemo(() => {
const arr = [...allEntries];
if (sortDirection === 'asc') {
arr.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
} else {
arr.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}
return arr;
}, [allEntries, sortDirection]);
const totalPages = Math.max(1, Math.ceil(sortedEntries.length / perPage));
const pagedEntries = useMemo(() => {
const start = (page - 1) * perPage;
return sortedEntries.slice(start, start + perPage);
}, [sortedEntries, page, perPage]);
// 直方图数据 // Logan.Li
const histogramData = useMemo(() => generateHistogramData(allEntries), [allEntries]);
// 字段列表 // Logan.Li
const fields = useMemo(() => extractFields(allEntries), [allEntries]);
// 执行查询 // Logan.Li
const runQuery = useCallback((datasourceName: string = 'default', timeRange: string = '1h') => {
setLoading(true);
setQueryStatus('precise');
const t0 = performance.now();
// 模拟异步查询 // Logan.Li
setTimeout(() => {
const entries = generateMockEntries(engine, 120);
const elapsed = Math.round(performance.now() - t0);
setAllEntries(entries);
setTotalCount(entries.length * 14); // 模拟更多总数
setQueryTime(elapsed);
setPage(1);
setLoading(false);
// 保存到历史 // Logan.Li
historyStorage.add({ engine, datasourceName, query, timeRange, resultCount: entries.length });
}, 600 + Math.random() * 800);
}, [engine, query]);
const toggleField = useCallback((name: string) => {
setSelectedFields(prev =>
prev.includes(name) ? prev.filter(f => f !== name) : [...prev, name]
);
}, []);
return {
// 查询状态
query, setQuery,
allEntries, setAllEntries,
loading, totalCount, queryTime, queryStatus,
// 视图
viewMode, setViewMode,
wrapLines, setWrapLines,
truncate, setTruncate,
selectedFields, setSelectedFields, toggleField,
// 分页
page, setPage,
perPage, setPerPage,
sortDirection, setSortDirection,
totalPages,
pagedEntries,
sortedEntries,
// 派生数据
histogramData,
fields,
// 对话框
alertDialog, setAlertDialog,
historyPanel, setHistoryPanel,
// 操作
runQuery,
};
}