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
+104
-1751
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 { LogDatasourceDialog } from './LogDatasourceDialog';
|
||||||
import type { LogEngine } from './log-center-types';
|
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() {
|
export function LogDatasourcePage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [sources, setSources] = useState<LogDataSource[]>(DEMO_FALLBACK);
|
const [sources, setSources] = useState<LogDataSource[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dialog, setDialog] = useState<{ open: boolean; engine: LogEngine; editing?: LogDataSource | null }>({
|
const [dialog, setDialog] = useState<{ open: boolean; engine: LogEngine; editing?: LogDataSource | null }>({
|
||||||
open: false, engine: 'loki', editing: null,
|
open: false, engine: 'loki', editing: null,
|
||||||
@@ -32,10 +26,9 @@ export function LogDatasourcePage() {
|
|||||||
try {
|
try {
|
||||||
const res = await logCenterApi.listDatasources();
|
const res = await logCenterApi.listDatasources();
|
||||||
const apiList: LogDataSource[] = Array.isArray(res) ? res : [];
|
const apiList: LogDataSource[] = Array.isArray(res) ? res : [];
|
||||||
// 真实数据源在前 + demo 兜底在后 // Logan.Li
|
setSources(apiList);
|
||||||
setSources([...apiList, ...DEMO_FALLBACK]);
|
|
||||||
} catch {
|
} catch {
|
||||||
setSources(DEMO_FALLBACK);
|
setSources([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -116,31 +109,28 @@ export function LogDatasourcePage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions', header: '操作', align: 'right', hideable: false,
|
key: 'actions', header: '操作', align: 'right', hideable: false,
|
||||||
render: (ds) => {
|
render: (ds) => (
|
||||||
const isDemo = ds.id < 0 || !ds.url;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-end gap-0.5">
|
<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)]"
|
<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" />
|
<Plug className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{!ds.is_default && (
|
{!ds.is_default && (
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-amber-400"
|
<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" />
|
<Star className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
<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" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-red-400"
|
<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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog';
|
||||||
import { logCenterApi, type LogDataSource } from '@/api/monitor/log-center';
|
import { logCenterApi, type LogDataSource } from '@/api/monitor/log-center';
|
||||||
import type { LogEngine, TimeRange, ResultTab, ViewMode, LogEntry, StatTable, LogCluster, QueryHistoryItem } from './types-v3';
|
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';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
// 默认最近5分钟 // Logan.Li
|
// 默认最近5分钟 // Logan.Li
|
||||||
@@ -24,12 +24,6 @@ const DEFAULT_TIME_RANGE: TimeRange = {
|
|||||||
type: 'relative',
|
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
|
// 引擎默认查询语句 // Logan.Li
|
||||||
const DEFAULT_QUERIES: Record<'loki' | 'elasticsearch', string> = {
|
const DEFAULT_QUERIES: Record<'loki' | 'elasticsearch', string> = {
|
||||||
loki: '{app="cex-admin-api"} |= ""',
|
loki: '{app="cex-admin-api"} |= ""',
|
||||||
@@ -81,22 +75,34 @@ const ALL_TEMPLATES: Record<'loki' | 'elasticsearch', { label: string; query: st
|
|||||||
// LogQL / Lucene 语法高亮 → HTML // Logan.Li
|
// LogQL / Lucene 语法高亮 → HTML // Logan.Li
|
||||||
function highlightSyntax(query: string, engine: 'loki' | 'elasticsearch'): string {
|
function highlightSyntax(query: string, engine: 'loki' | 'elasticsearch'): string {
|
||||||
if (!query) return '<span style="color:transparent">.</span>';
|
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') {
|
if (engine === 'loki') {
|
||||||
return query
|
// 单正则 + 回调:一次匹配,避免链式 replace 对已生成颜色值的二次匹配 // Logan.Li
|
||||||
.replace(/([a-zA-Z_][\w-]*)(?==)/g, '<s c="#f59e0b">$1</s>')
|
return esc.replace(
|
||||||
.replace(/("(?:[^"\\]|\\.)*")/g, '<s c="#22c55e">$1</s>')
|
/("(?:[^"\\]|\\.)*")|([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,
|
||||||
.replace(/(\|=|\!=|=~|!~|=)/g, '<s c="#f43f5e">$1</s>')
|
(m, str, ident, pipe, fn, brace, op) => {
|
||||||
.replace(/(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)/g, '<s c="#8b5cf6">$1</s>')
|
if (str) return span('#22c55e', str);
|
||||||
.replace(/\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b/g, '<s c="#06b6d4">$1</s>')
|
if (ident) return span('#f59e0b', ident);
|
||||||
.replace(/(\{|\})/g, '<s c="#f59e0b">$1</s>')
|
if (pipe) return span('#8b5cf6', pipe);
|
||||||
.replace(/<s c=/g, '<span style="color=') + '<span style="color:transparent">.</span>';
|
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
|
return esc.replace(
|
||||||
.replace(/\b(AND|OR|NOT)\b/g, '<s c="#f43f5e">$1</s>')
|
/("(?:[^"\\]|\\.)*")|\b(AND|OR|NOT)\b|([a-zA-Z_@][\w.-]*)(?=:)|([*?~^[\]()])/g,
|
||||||
.replace(/([a-zA-Z_@][\w.-]*):/g, '<s c="#f59e0b">$1</s>:')
|
(m, str, kw, field, punct) => {
|
||||||
.replace(/("(?:[^"\\]|\\.)*")/g, '<s c="#22c55e">$1</s>')
|
if (str) return span('#22c55e', str);
|
||||||
.replace(/(\*|\?|\~|\^|\[|\]|\(|\))/g, '<s c="#8b5cf6">$1</s>')
|
if (kw) return span('#f43f5e', kw);
|
||||||
.replace(/<s c=/g, '<span style="color=') + '<span style="color:transparent">.</span>';
|
if (field) return span('#f59e0b', field);
|
||||||
|
if (punct) return span('#8b5cf6', punct);
|
||||||
|
return m;
|
||||||
|
},
|
||||||
|
) + '<span style="color:transparent">.</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语法高亮输入框:底层高亮 + 上层透明输入 // Logan.Li
|
// 语法高亮输入框:底层高亮 + 上层透明输入 // Logan.Li
|
||||||
@@ -132,15 +138,13 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
const meta = ENGINE_META[effEngine];
|
const meta = ENGINE_META[effEngine];
|
||||||
const badge = ENGINE_BADGE[effEngine];
|
const badge = ENGINE_BADGE[effEngine];
|
||||||
|
|
||||||
// 从后端加载真实数据源,同时保留 demo 兜底 // Logan.Li
|
// 从后端加载真实数据源(无 demo 兜底,无源时引导用户前往日志库配置)// Logan.Li
|
||||||
const [sources, setSources] = useState<LogDataSource[]>(DEMO_SOURCES[effEngine]);
|
const [sources, setSources] = useState<LogDataSource[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logCenterApi.listDatasources(effEngine).then((res: any) => {
|
logCenterApi.listDatasources(effEngine).then((res: any) => {
|
||||||
const list: LogDataSource[] = Array.isArray(res) ? res : (res?.list || []);
|
const list: LogDataSource[] = Array.isArray(res) ? res : (res?.list || []);
|
||||||
// 合并真实数据源 + demo 兜底(真实源在前) // Logan.Li
|
setSources(list);
|
||||||
if (list.length > 0) setSources([...list, ...DEMO_SOURCES[effEngine]]);
|
}).catch(() => setSources([]));
|
||||||
else setSources(DEMO_SOURCES[effEngine]);
|
|
||||||
}).catch(() => setSources(DEMO_SOURCES[effEngine]));
|
|
||||||
}, [effEngine]);
|
}, [effEngine]);
|
||||||
|
|
||||||
const [query, setQuery] = useState(DEFAULT_QUERIES[effEngine]);
|
const [query, setQuery] = useState(DEFAULT_QUERIES[effEngine]);
|
||||||
@@ -168,13 +172,15 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
const [saveMenuOpen, setSaveMenuOpen] = useState(false);
|
const [saveMenuOpen, setSaveMenuOpen] = useState(false);
|
||||||
const [hasQueried, setHasQueried] = useState(false);
|
const [hasQueried, setHasQueried] = useState(false);
|
||||||
|
|
||||||
// 数据源切换后更新 dsId // Logan.Li
|
// 数据源加载后选中默认或首个(无源时保持空)// Logan.Li
|
||||||
useEffect(() => {
|
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]);
|
}, [sources]);
|
||||||
|
|
||||||
const currentSource = sources.find(s => String(s.id) === dsId);
|
const currentSource = sources.find(s => String(s.id) === dsId);
|
||||||
const isDemo = currentSource && (currentSource.id < 0 || !currentSource.url);
|
|
||||||
|
|
||||||
const fields = useMemo(() => extractFields(entries), [entries]);
|
const fields = useMemo(() => extractFields(entries), [entries]);
|
||||||
const commonLabels = useMemo(() => extractCommonLabels(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 totalPages = Math.max(1, Math.ceil(sortedEntries.length / perPage));
|
||||||
const pagedEntries = useMemo(() => { const s = (page - 1) * perPage; return sortedEntries.slice(s, s + perPage); }, [sortedEntries, page, perPage]);
|
const 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 () => {
|
const handleRun = useCallback(async () => {
|
||||||
if (!currentSource) return;
|
if (!currentSource) return;
|
||||||
const dsIdNum = currentSource.id;
|
const dsIdNum = currentSource.id;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const t0 = performance.now();
|
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 {
|
try {
|
||||||
const startISO = (timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z')).replace(' ', 'T');
|
const startISO = (timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z')).replace(' ', 'T');
|
||||||
const endISO = (timeRange.end + (timeRange.end.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));
|
setHistogramData(generateHistogramData(mapped));
|
||||||
setTotalCount(data?.total || mapped.length);
|
setTotalCount(data?.total || mapped.length);
|
||||||
setQueryTime(Math.round(performance.now() - t0));
|
setQueryTime(Math.round(performance.now() - t0));
|
||||||
setStatTable(generateMockStatTable(effEngine as LogEngine));
|
// 统计图表 / 聚类基于真实返回条目聚合(无 mock)// Logan.Li
|
||||||
setClusters(generateMockClusters());
|
setStatTable(buildStatTableFromEntries(mapped));
|
||||||
|
setClusters(buildClustersFromEntries(mapped));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setHasQueried(true);
|
setHasQueried(true);
|
||||||
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mapped.length });
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
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 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); };
|
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} />
|
<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)' }}>
|
<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>
|
</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" />} 查询
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -305,6 +300,13 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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} />
|
<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
|
// 真实统计聚合 — 基于查询返回的真实条目按级别计数(无 mock)// Logan.Li
|
||||||
export function generateMockEntries(engine: LogEngine, count: number = 100): LogEntry[] {
|
// level 由后端 flattenLokiStreams / flattenESEntries 提取;缺失归 unknown
|
||||||
const entries: LogEntry[] = [];
|
export function buildStatTableFromEntries(entries: LogEntry[]): StatTable {
|
||||||
const now = Date.now();
|
const counter = new Map<string, number>();
|
||||||
const LEVELS = ['INFO', 'WARN', 'ERROR', 'DEBUG'];
|
entries.forEach(e => {
|
||||||
const APP = 'cex-admin-api';
|
const key = (e.level || e.labels?.level || 'unknown').toString();
|
||||||
const THREADS = ['XNIO-1 task-2', 'XNIO-1 task-1', 'XNIO-1 I/O-1'];
|
counter.set(key, (counter.get(key) || 0) + 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,
|
|
||||||
});
|
});
|
||||||
}
|
const rows = Array.from(counter.entries())
|
||||||
return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
.map(([level, count]) => ({ level, count }))
|
||||||
}
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
// 生成统计表格 Mock // Logan.Li
|
|
||||||
export function generateMockStatTable(engine: LogEngine): StatTable {
|
|
||||||
const methods = ['GET', 'POST', 'PUT', 'DELETE'];
|
|
||||||
const levels = ['info', 'warn', 'error'];
|
|
||||||
if (engine === 'loki') {
|
|
||||||
return {
|
return {
|
||||||
columns: [{ name: 'level', type: 'string' }, { name: 'count', type: 'number' }],
|
columns: [
|
||||||
rows: levels.map(l => ({ level: l, count: Math.floor(Math.random() * 5000 + 1000) })),
|
{ name: 'level', type: 'string' },
|
||||||
};
|
{ name: 'count', type: 'number' },
|
||||||
}
|
],
|
||||||
return {
|
rows,
|
||||||
columns: [{ name: 'method', type: 'string' }, { name: 'count', type: 'number' }],
|
|
||||||
rows: methods.map(m => ({ method: m, count: Math.floor(Math.random() * 2000 + 100) })),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成聚类 Mock // Logan.Li
|
// 真实日志聚类 — 将 message 中数字/十六进制/IPv4/UUID 泛化为 * 后分组计数 // Logan.Li
|
||||||
export function generateMockClusters(): LogCluster[] {
|
export function buildClustersFromEntries(entries: LogEntry[]): LogCluster[] {
|
||||||
return [
|
const buckets = new Map<string, { count: number; sample: string }>();
|
||||||
{ pattern: 'admin getBalanceFlows response. orgId=*, brokerUserId=*', count: 4523, sample: 'admin getBalanceFlows response. orgId=10001, brokerUserId=97164001', percentage: 37.7 },
|
const generalize = (msg: string): string => {
|
||||||
{ pattern: 'admin getBrokerUser resolved. orgId=*, brokerUserId=*', count: 3281, sample: 'admin getBrokerUser resolved. orgId=10002, brokerUserId=97164234', percentage: 27.3 },
|
if (!msg) return '';
|
||||||
{ pattern: 'admin authenticate response. orgId=*, brokerUserId=*', count: 2156, sample: 'admin authenticate response. orgId=10003, brokerUserId=97164567', percentage: 18.0 },
|
return msg
|
||||||
{ pattern: 'admin getOrderList failed. orgId=*, brokerUserId=*', count: 1024, sample: 'admin getOrderList failed. orgId=10004, brokerUserId=97164890', percentage: 8.5 },
|
.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(优先于一般十六进制)
|
||||||
{ pattern: 'admin updateBalance response. orgId=*, brokerUserId=*', count: 890, sample: 'admin updateBalance response. orgId=10001, brokerUserId=97164123', percentage: 7.4 },
|
.replace(/\b\d{1,3}(\.\d{1,3}){3}\b/g, '*') // IPv4
|
||||||
{ pattern: 'Other patterns', count: 126, sample: '...', percentage: 1.1 },
|
.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[] {
|
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))); },
|
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
|
// 快捷查询模板 // Logan.Li
|
||||||
export const QUICK_TEMPLATES: Record<LogEngine, { label: string; query: string }[]> = {
|
export const QUICK_TEMPLATES: Record<LogEngine, { label: string; query: string }[]> = {
|
||||||
loki: [
|
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