refactor(log-center): 清理重复/死代码组件 — 移除 DatasourceCard/DatasourceDialog/DeleteConfirmDialog/EsQueryPanel/LokiQueryPanel/LogResultsTable/LogLibraryPage/StatusDot/useLogQuery/logLibraryTypes 10 个冗余组件 (1752 行删除)
This commit is contained in:
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user