feat(log-center): 日志中心 v4 全量组件 — 统一查询面板/时间选择器/统计图表/聚类/数据源管理

This commit is contained in:
attacker committed 2026-06-27 11:41:50 +08:00
1 parent 880cf050fe
commit bdf3c2daf4
30 files changed
+4276 -136

No files matched your search

@@ -0,0 +1,245 @@
// 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>
);
}
@@ -0,0 +1,394 @@
// 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>
);
}
@@ -0,0 +1,113 @@
// 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>
);
}
@@ -0,0 +1,428 @@
// EnhancedLogResults.tsx — Grafana 风格日志结果展示 // Logan.Li
import { useState, useMemo } from 'react';
import { ChevronDown, ChevronRight, BarChart3, Plus, Search, Copy, Check } from 'lucide-react';
import type { LogEntry, ViewMode } from './types-v3';
type ViewDensity = 'compact' | 'comfortable' | 'loose';
interface Props {
entries: LogEntry[];
loading?: boolean;
viewMode: ViewMode;
wrapLines: boolean;
truncate: boolean;
density?: ViewDensity;
fieldSearch?: string;
// Grafana 风格新增 // Logan.Li
showTime?: boolean;
onShowTimeChange?: (v: boolean) => void;
uniqueLabels?: boolean;
onUniqueLabelsChange?: (v: boolean) => void;
prettifyJSON?: boolean;
onPrettifyJSONChange?: (v: boolean) => void;
dedupMode?: DedupMode;
onDedupModeChange?: (m: DedupMode) => void;
sortOrder?: 'newest' | 'oldest';
onSortOrderChange?: (o: 'newest' | 'oldest') => void;
// Common labels
commonLabels?: Record<string, string>;
// 标签操作回调
onLabelFilter?: (key: string, value: string, op: 'include' | 'exclude') => void;
}
type DedupMode = 'none' | 'exact' | 'numbers' | 'signature';
function levelBadge(level?: string): string {
const l = (level || '').toLowerCase();
if (l === 'error' || l === 'fatal' || l === 'err') return 'bg-red-500/15 text-red-400 border-red-500/30';
if (l === 'warn' || l === 'warning') return 'bg-amber-500/15 text-amber-400 border-amber-500/30';
if (l === 'info') return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
if (l === 'debug') return 'bg-slate-500/15 text-slate-400 border-slate-500/30';
return 'bg-slate-500/15 text-slate-400 border-slate-500/30';
}
function levelColorText(level?: string): string {
const l = (level || '').toLowerCase();
if (l === 'error' || l === 'fatal' || l === 'err') return '#f87171';
if (l === 'warn' || l === 'warning') return '#fbbf24';
if (l === 'info') return '#34d399';
if (l === 'debug') return '#94a3b8';
return '#94a3b8';
}
const DENSITY_PADDING: Record<ViewDensity, string> = {
compact: 'py-0.5',
comfortable: 'py-1.5',
loose: 'py-2.5',
};
// ===== Dedup 处理 ===== //
function dedupEntries(entries: LogEntry[], mode: DedupMode): LogEntry[] {
if (mode === 'none') return entries;
const seen = new Set<string>();
return entries.filter(e => {
let key: string;
if (mode === 'exact') key = e.message;
else if (mode === 'numbers') key = e.message.replace(/\d+/g, '');
else key = `${e.level || ''}:${e.labels?.service || ''}:${e.message.slice(0, 80)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// ===== 提取 Common Labels ===== //
export function extractCommonLabels(entries: LogEntry[]): Record<string, string> | null {
if (entries.length === 0) return null;
const first = entries[0].labels || {};
const common: Record<string, string> = {};
for (const [k, v] of Object.entries(first)) {
if (entries.every(e => e.labels?.[k] === v)) common[k] = v;
}
return Object.keys(common).length > 0 ? common : null;
}
// ===== 检测结构化字段(从消息中解析)=====
function detectFields(raw: string): { name: string; value: string }[] {
const fields: { name: string; value: string }[] = [];
try {
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) {
for (const [k, v] of Object.entries(parsed)) {
fields.push({ name: k, value: String(v) });
}
}
} catch {
// 非 JSON,尝试 key=value 模式
const regex = /(\w+)=([^,\s]+)/g;
let m;
while ((m = regex.exec(raw)) !== null) {
fields.push({ name: m[1], value: m[2] });
}
}
return fields;
}
// ===== 原始日志视图(Grafana 风格)=====
function RawLogView({
entry, wrap, trunc, showTime, onLabelFilter,
}: {
entry: LogEntry; wrap: boolean; trunc: boolean;
showTime: boolean; onLabelFilter?: (k: string, v: string, op: 'include' | 'exclude') => void;
}) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const labels = entry.labels || {};
const detected = useMemo(() => detectFields(entry.raw || entry.message), [entry]);
const copyRaw = () => {
navigator.clipboard.writeText(entry.raw || entry.message).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1500);
});
};
return (
<div className="group" style={{ borderBottom: '1px solid var(--dg-border)' }}>
{/* 日志行 */}
<div
className="flex items-start gap-2 cursor-pointer hover:opacity-90 transition-colors"
style={{ background: 'var(--dg-bg-secondary)', padding: '4px 12px' }}
onClick={() => setExpanded(!expanded)}
>
{/* 展开图标 */}
<span className="flex-shrink-0 mt-0.5" style={{ color: 'var(--dg-text-muted)' }}>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</span>
{/* 时间戳 */}
{showTime && (
<span className="text-[11px] font-mono flex-shrink-0 w-[140px] mt-0.5" style={{ color: 'var(--dg-text-muted)' }}>
{entry.timestamp}
</span>
)}
{/* 日志级别 */}
{entry.level && (
<span className="text-[10px] font-medium flex-shrink-0 mt-0.5" style={{ color: levelColorText(entry.level), minWidth: '40px' }}>
{entry.level.toUpperCase()}
</span>
)}
{/* 日志内容 */}
<span className={`text-xs flex-1 min-w-0 ${wrap ? 'whitespace-pre-wrap break-all' : 'truncate'}`} style={{ color: 'var(--dg-text-secondary)' }}>
{trunc && entry.message.length > 300 && !expanded ? entry.message.slice(0, 300) + '...' : entry.message}
</span>
{/* 复制按钮(hover 显示)*/}
<button
onClick={e => { e.stopPropagation(); copyRaw(); }}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity mt-0.5"
style={{ color: 'var(--dg-text-muted)' }}
>
{copied ? <Check className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />}
</button>
</div>
{/* 展开详情:Log labels + Detected fields */}
{expanded && (
<div className="animate-fade-in" style={{ background: 'var(--dg-bg-secondary)', padding: '8px 12px 12px 36px' }}>
{/* Log labels */}
{Object.keys(labels).length > 0 && (
<div className="mb-3">
<p className="text-[10px] font-medium mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>Log labels</p>
<div className="space-y-1">
{Object.entries(labels).map(([k, v]) => (
<div key={k} className="flex items-center gap-2 group/label">
<BarChart3 className="h-3 w-3 flex-shrink-0 opacity-0 group-hover/label:opacity-100 transition-opacity" style={{ color: 'var(--dg-text-muted)' }} />
<button
className="opacity-0 group-hover/label:opacity-100 transition-opacity flex-shrink-0"
style={{ color: '#22c55e' }}
onClick={e => { e.stopPropagation(); onLabelFilter?.(k, v, 'include'); }}
title={`Filter for ${k}=${v}`}
>
<Plus className="h-3 w-3" />
</button>
<span className="text-[11px] font-mono w-[160px] truncate flex-shrink-0" style={{ color: 'var(--dg-text-primary)' }}>{k}</span>
<span className="text-[11px] font-mono flex-1 truncate" style={{ color: 'var(--dg-text-secondary)' }}>{v}</span>
</div>
))}
</div>
</div>
)}
{/* Detected fields */}
{detected.length > 0 && (
<div>
<p className="text-[10px] font-medium mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>Detected fields</p>
<div className="space-y-1">
{detected.map(({ name, value }) => (
<div key={name} className="flex items-center gap-2 group/field">
<BarChart3 className="h-3 w-3 flex-shrink-0 opacity-0 group-hover/field:opacity-100 transition-opacity" style={{ color: 'var(--dg-text-muted)' }} />
<button
className="opacity-0 group-hover/field:opacity-100 transition-opacity flex-shrink-0"
style={{ color: '#22c55e' }}
onClick={e => { e.stopPropagation(); onLabelFilter?.(name, value, 'include'); }}
>
<Plus className="h-3 w-3" />
</button>
<button
className="opacity-0 group-hover/field:opacity-100 transition-opacity flex-shrink-0"
style={{ color: '#ef4444' }}
onClick={e => { e.stopPropagation(); onLabelFilter?.(name, value, 'exclude'); }}
>
<Search className="h-3 w-3" />
</button>
<span className="text-[11px] font-mono w-[160px] truncate flex-shrink-0" style={{ color: 'var(--dg-text-primary)' }}>{name}</span>
<span className="text-[11px] font-mono flex-1 break-all" style={{ color: 'var(--dg-text-secondary)' }}>{value}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ===== 字段视图 ===== //
function FieldView({ entries, wrap, trunc, search }: { entries: LogEntry[]; wrap: boolean; trunc: boolean; search?: string }) {
const columns = useMemo(() => {
const colSet = new Set<string>();
entries.forEach(e => { if (e.labels) Object.keys(e.labels).forEach(k => colSet.add(k)); });
colSet.add('timestamp'); colSet.add('message');
const arr = Array.from(colSet);
return search ? arr.filter(c => c.toLowerCase().includes(search.toLowerCase())) : arr;
}, [entries, search]);
return (
<div className="overflow-x-auto">
<table className="w-full text-xs" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--dg-bg-tertiary)', borderBottom: '1px solid var(--dg-border)' }}>
{columns.map(col => (
<th key={col} className="text-left px-2 py-1.5 font-medium whitespace-nowrap" style={{ color: 'var(--dg-text-muted)', fontSize: '10px', textTransform: 'uppercase' }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
<tr key={i} className="hover:opacity-80 transition-colors" style={{ borderBottom: '1px solid var(--dg-border)' }}>
{columns.map(col => {
let val: string;
if (col === 'timestamp') val = e.timestamp;
else if (col === 'message') val = e.message;
else val = e.labels?.[col] || '—';
return (
<td key={col} className={`px-2 py-1.5 max-w-[300px] ${!wrap ? 'truncate' : 'break-all'}`} style={{ color: 'var(--dg-text-secondary)' }}>
{trunc && val.length > 100 ? val.slice(0, 100) + '...' : val}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
// ===== 表格视图 ===== //
function TableView({ entries, wrap, trunc }: { entries: LogEntry[]; wrap: boolean; trunc: boolean }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-xs" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--dg-bg-tertiary)', borderBottom: '1px solid var(--dg-border)' }}>
<th className="text-left px-3 py-2 font-medium w-[160px]" style={{ color: 'var(--dg-text-muted)', fontSize: '10px' }}>Time</th>
<th className="text-left px-3 py-2 font-medium w-[72px]" style={{ color: 'var(--dg-text-muted)', fontSize: '10px' }}>Level</th>
<th className="text-left px-3 py-2 font-medium" style={{ color: 'var(--dg-text-muted)', fontSize: '10px' }}>Message</th>
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
<tr key={i} className="hover:opacity-80 transition-colors" style={{ borderBottom: '1px solid var(--dg-border)' }}>
<td className="px-3 py-2 font-mono text-[11px] whitespace-nowrap" style={{ color: 'var(--dg-text-muted)' }}>{e.timestamp}</td>
<td className="px-3 py-2">
{e.level ? <span className={`text-[10px] px-1.5 py-0.5 rounded border ${levelBadge(e.level)}`}>{e.level}</span> : '—'}
</td>
<td className={`px-3 py-2 ${wrap ? 'whitespace-pre-wrap break-all' : 'truncate'} max-w-[600px]`} style={{ color: 'var(--dg-text-secondary)' }}>
{trunc && e.message.length > 200 ? e.message.slice(0, 200) + '...' : e.message}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ===== 主组件 ===== //
export function EnhancedLogResults({
entries, loading, viewMode, wrapLines, truncate, density = 'comfortable',
showTime = true, onShowTimeChange,
uniqueLabels = false, onUniqueLabelsChange,
prettifyJSON = false, onPrettifyJSONChange,
dedupMode = 'none', onDedupModeChange,
sortOrder = 'newest', onSortOrderChange,
commonLabels,
onLabelFilter,
}: Props) {
// 去重处理
const dedupedEntries = useMemo(() => dedupEntries(entries, dedupMode), [entries, dedupMode]);
// 排序处理
const sortedEntries = useMemo(() => {
if (sortOrder === 'oldest') return [...dedupedEntries].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
return dedupedEntries;
}, [dedupedEntries, sortOrder]);
if (loading) {
return (
<div className="border rounded-lg overflow-hidden animate-pulse" style={{ borderColor: 'var(--dg-border)' }}>
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className={`${DENSITY_PADDING[density]} px-3`} style={{ borderBottom: '1px solid var(--dg-border)' }}>
<div className="h-4 rounded" style={{ background: 'var(--dg-bg-tertiary)', width: `${60 + Math.random() * 40}%` }} />
</div>
))}
</div>
);
}
if (entries.length === 0) {
return (
<div className="border rounded-lg overflow-hidden flex items-center justify-center" style={{ borderColor: 'var(--dg-border)', minHeight: '200px' }}>
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>暂无日志</span>
</div>
);
}
return (
<div>
{/* Common labels */}
{commonLabels && Object.keys(commonLabels).length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mb-2 px-1">
<span className="text-[11px]" style={{ color: 'var(--dg-text-muted)' }}>Common labels:</span>
{Object.entries(commonLabels).map(([k, v]) => (
<span key={k} className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)' }}>
{k}={v}
</span>
))}
</div>
)}
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-3 mb-2 px-1">
{/* Time 开关 */}
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
<input type="checkbox" checked={showTime} onChange={e => onShowTimeChange?.(e.target.checked)} className="h-3 w-3 rounded" />
Time
</label>
{/* Unique labels */}
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
<input type="checkbox" checked={uniqueLabels} onChange={e => onUniqueLabelsChange?.(e.target.checked)} className="h-3 w-3 rounded" />
Unique labels
</label>
{/* Wrap lines */}
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
<input type="checkbox" checked={wrapLines} onChange={() => {}} className="h-3 w-3 rounded" disabled />
Wrap lines
</label>
{/* Prettify JSON */}
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
<input type="checkbox" checked={prettifyJSON} onChange={e => onPrettifyJSONChange?.(e.target.checked)} className="h-3 w-3 rounded" />
Prettify JSON
</label>
{/* Dedup */}
<div className="flex items-center gap-1 text-[11px]">
<span style={{ color: 'var(--dg-text-muted)' }}>Dedup</span>
{(['none', 'exact', 'numbers', 'signature'] as DedupMode[]).map(m => (
<button
key={m}
onClick={() => onDedupModeChange?.(m)}
className="px-1.5 py-0.5 rounded text-[10px] transition-colors"
style={{
background: dedupMode === m ? 'var(--dg-bg-tertiary)' : 'transparent',
color: dedupMode === m ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)',
border: dedupMode === m ? '1px solid var(--dg-border)' : '1px solid transparent',
}}
>
{m.charAt(0).toUpperCase() + m.slice(1)}
</button>
))}
</div>
{/* Sort order */}
<div className="flex items-center gap-1 ml-auto">
<span className="text-[11px]" style={{ color: 'var(--dg-text-muted)' }}>Display results</span>
{(['newest', 'oldest'] as const).map(o => (
<button
key={o}
onClick={() => onSortOrderChange?.(o)}
className="px-2 py-0.5 rounded text-[10px] transition-colors"
style={{
background: sortOrder === o ? 'var(--dg-bg-tertiary)' : 'transparent',
color: sortOrder === o ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)',
border: sortOrder === o ? '1px solid var(--dg-border)' : '1px solid transparent',
}}
>
{o === 'newest' ? 'Newest first' : 'Oldest first'}
</button>
))}
</div>
</div>
{/* 结果内容 */}
<div className="border rounded-lg overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
{viewMode === 'raw' && sortedEntries.map((e, i) => (
<RawLogView key={i} entry={e} wrap={wrapLines} trunc={truncate} showTime={showTime} onLabelFilter={onLabelFilter} />
))}
{viewMode === 'field' && <FieldView entries={sortedEntries} wrap={wrapLines} trunc={truncate} />}
{viewMode === 'table' && <TableView entries={sortedEntries} wrap={wrapLines} trunc={truncate} />}
</div>
</div>
);
}
@@ -0,0 +1,34 @@
// LogAlertBell.tsx — 小铃铛告警关联组件 // Logan.Li
import { Bell } from 'lucide-react';
interface Props {
hasAlertRule?: boolean;
onClick: () => void;
size?: 'sm' | 'md' | 'lg';
}
const SIZES = {
sm: { btn: 'h-7 w-7', icon: 14 },
md: { btn: 'h-9 w-9', icon: 16 },
lg: { btn: 'h-10 w-10', icon: 18 },
};
export function LogAlertBell({ hasAlertRule, onClick, size = 'md' }: Props) {
const s = SIZES[size];
return (
<button
onClick={onClick}
className={`relative ${s.btn} flex items-center justify-center rounded-md border transition-colors hover:opacity-80`}
style={{ borderColor: 'var(--dg-border)', background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-muted)' }}
title={hasAlertRule ? '已关联告警策略' : '关联告警策略'}
>
<Bell style={{ width: s.icon, height: s.icon }} />
{hasAlertRule && (
<span
className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full ring-1"
style={{ background: '#ef4444', outline: '2px solid var(--dg-bg-secondary)' }}
/>
)}
</button>
);
}
@@ -1,136 +1,190 @@
// LogAlertFromQueryDialog — 叮当:将查询转为日志告警规则 // Logan.Li
// LogAlertFromQueryDialog.tsx — 从查询创建告警规则 // Logan.Li
import { useState, useEffect } from 'react';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
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 { Loader2, BellRing } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { alertApi } from '@/api';
import type { LogQueryContext } from './log-center-types';
import { X, BellRing, Loader2, Flame, Database, Cloud } from 'lucide-react';
import type { LogEngine } from './types-v3';
export function LogAlertFromQueryDialog({
open, onOpenChange, ctx, onSaved,
}: {
interface LogQueryContext {
engine: LogEngine;
datasourceId?: number;
datasourceName?: string;
query: string;
index?: string;
timeRange?: string;
}
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
ctx: LogQueryContext | null;
onSaved?: () => void;
}) {
const { toast } = useToast();
}
const ENGINE_META: Record<LogEngine, { name: string; Icon: typeof Flame; color: string }> = {
loki: { name: 'Loki', Icon: Flame, color: '#06b6d4' },
elasticsearch: { name: 'ES', Icon: Database, color: '#22c55e' },
sls: { name: 'SLS', Icon: Cloud, color: '#f97316' },
};
export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Props) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [expression, setExpression] = useState('');
const [severity, setSeverity] = useState('warning');
const [duration, setDuration] = useState(300);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!open || !ctx) return;
const engineLabel = ctx.engine === 'loki' ? 'Loki' : 'ES';
setName(`${engineLabel} 日志告警`);
setDescription(
`数据源: ${ctx.datasourceName || '—'} · 范围: ${ctx.timeRange || '—'}${ctx.index ? ` · 索引: ${ctx.index}` : ''}`,
);
const meta = ENGINE_META[ctx.engine];
setName(`${meta.name} 日志告警`);
setDescription(`数据源: ${ctx.datasourceName || '—'} · 范围: ${ctx.timeRange || '—'}${ctx.index ? ` · 索引: ${ctx.index}` : ''}`);
if (ctx.engine === 'loki') {
setExpression(`count_over_time(${wrapLogQL(ctx.query)}[5m]) > 0`);
setExpression(`count_over_time(${ctx.query}[5m]) > 0`);
} else if (ctx.engine === 'sls') {
setExpression(ctx.query || '*');
} else {
setExpression(ctx.query || '*');
}
setSaved(false);
}, [open, ctx]);
const handleSave = async () => {
if (!name.trim() || !expression.trim() || !ctx) return;
if (!open || !ctx) return null;
const meta = ENGINE_META[ctx.engine];
const { Icon, color } = meta;
const handleSave = () => {
if (!name.trim() || !expression.trim()) return;
setSaving(true);
try {
await alertApi.createRule({
name: name.trim(),
description: description.trim(),
type: 'log',
source: ctx.engine === 'loki' ? 'loki' : 'elasticsearch',
expression: expression.trim(),
severity,
duration,
notify_channels: [],
});
toast({ title: '告警规则已创建', description: '可在「日志告警」Tab 中继续配置通知渠道' });
onOpenChange(false);
onSaved?.();
} catch (e: any) {
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setTimeout(() => {
setSaving(false);
}
setSaved(true);
onSaved?.();
setTimeout(() => onOpenChange(false), 800);
}, 500);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<BellRing className="h-5 w-5 text-amber-400" />
叮当 · 添加告警
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
将当前查询条件保存为日志告警规则,命中时触发通知
</DialogDescription>
</DialogHeader>
{!ctx?.query?.trim() ? (
<p className="text-sm text-amber-400/90 py-4">请先在 Loki 或 ES 查询页输入查询语句</p>
) : (
<div className="space-y-3">
<>
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={() => onOpenChange(false)} />
<div
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-lg rounded-lg border shadow-xl overflow-hidden"
style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}
>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-2">
<BellRing className="h-5 w-5" style={{ color: '#f59e0b' }} />
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>创建告警规则</span>
</div>
<button
onClick={() => onOpenChange(false)}
className="h-7 w-7 flex items-center justify-center rounded transition-colors"
style={{ color: 'var(--dg-text-muted)' }}
>
<X className="h-4 w-4" />
</button>
</div>
{/* 内容 */}
<div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto">
{/* 查询来源信息 */}
<div className="flex items-center gap-2 p-2 rounded" style={{ background: 'var(--dg-bg-tertiary)' }}>
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded font-medium" style={{ background: `${color}15`, color }}>
<Icon className="h-3 w-3" />{meta.name}
</span>
<span className="text-[11px] truncate" style={{ color: 'var(--dg-text-secondary)' }}>{ctx.datasourceName || '默认数据源'}</span>
<span className="text-[10px] ml-auto" style={{ color: 'var(--dg-text-muted)' }}>{ctx.timeRange || '1h'}</span>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>规则名称 *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full h-9 px-3 text-xs rounded dg-input"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
/>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>描述</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
className="w-full px-3 py-2 text-xs rounded dg-input resize-none"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
/>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件 *</label>
<textarea
value={expression}
onChange={e => setExpression(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-xs font-mono rounded dg-input"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
/>
<p className="text-[10px] mt-1" style={{ color: 'var(--dg-text-muted)' }}>
{ctx.engine === 'loki' ? 'LogQL 告警表达式' : ctx.engine === 'sls' ? 'SLS SQL/SPL 查询' : 'ES Lucene 查询'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">规则名称</Label>
<Input value={name} onChange={e => setName(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</label>
<select
value={severity}
onChange={e => setSeverity(e.target.value)}
className="w-full h-9 px-2 text-xs rounded dg-input"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
>
<option value="info">提示</option>
<option value="warning">警告</option>
<option value="critical">严重</option>
</select>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">描述</Label>
<Textarea value={description} onChange={e => setDescription(e.target.value)} rows={2} className="mt-1 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] resize-none" />
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">触发条件</Label>
<Textarea value={expression} onChange={e => setExpression(e.target.value)} rows={4} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
{ctx.engine === 'loki' ? 'LogQL 告警表达式,默认 count_over_time' : 'ES Lucene 查询,匹配到日志即告警'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">级别</Label>
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full h-9 mt-1 px-2 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm">
<option value="info">提示</option>
<option value="warning">警告</option>
<option value="critical">严重</option>
</select>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">持续(秒)</Label>
<Input type="number" value={duration} onChange={e => setDuration(Number(e.target.value) || 300)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
</div>
<div className="rounded-md bg-[var(--dg-bg-tertiary)]/60 p-2 text-[10px] font-mono text-[var(--dg-text-secondary)] break-all">
原始查询: {ctx.query}
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>持续(秒)</label>
<input
type="number"
value={duration}
onChange={e => setDuration(Number(e.target.value) || 300)}
className="w-full h-9 px-2 text-xs rounded dg-input"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving || !ctx?.query?.trim()}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
创建规则
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 原始查询 */}
<div className="p-2 rounded text-[10px] font-mono break-all" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
原始查询: {ctx.query}
</div>
</div>
{/* 底部 */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
<button
onClick={() => onOpenChange(false)}
className="h-8 px-3 rounded text-xs transition-colors"
style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}
>
取消
</button>
<button
onClick={handleSave}
disabled={saving || !name.trim() || !expression.trim() || saved}
className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1.5 transition-opacity disabled:opacity-50"
style={{ background: 'var(--dg-blue)' }}
>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{saved ? '已创建' : '创建规则'}
</button>
</div>
</div>
</>
);
}
/** 为 count_over_time 包裹 selector // Logan.Li */
function wrapLogQL(q: string): string {
const t = q.trim();
if (t.startsWith('{')) return t;
return `{job=~".+"} |= "${t.replace(/"/g, '\\"')}"`;
}
@@ -1,25 +1,25 @@
// LogCenter — 日志中心(侧边栏子菜单驱动:Loki / ES / 日志告警)// Logan.Li
// LogCenter — 日志中心 v4(侧边栏子菜单驱动:Loki查询 / ES查询 / 日志库 / 日志告警)// Logan.Li
import { useState } from 'react';
import { Navigate, useParams } from 'react-router-dom';
import { ScrollText } from 'lucide-react';
import { LogQueryProvider } from './LogQueryContext';
import { LokiQueryPanel } from './LokiQueryPanel';
import { EsQueryPanel } from './EsQueryPanel';
import { UnifiedQueryPanel } from './UnifiedQueryPanel';
import { LogLibraryPage } from './LogLibraryPage';
import { LogAlertsPanel } from './LogAlertsPanel';
import { LogDingAssistant } from './LogDingAssistant';
import { LogCenterArchBanner } from './LogCenterArchBanner';
import type { LogCenterTab } from './log-center-types';
import type { LogEngine } from './types-v3';
const SECTION_META: Record<LogCenterTab, { title: string; description: string }> = {
loki: { title: 'Loki 查询', description: '对接外部 Loki · LogQL 实时检索' },
es: { title: 'ES 查询', description: '对接 Elasticsearch · 索引日志检索' },
const SECTION_META: Record<string, { title: string; description: string; engine?: LogEngine }> = {
loki: { title: 'Loki 查询', description: '对接自建 Loki · LogQL 实时检索', engine: 'loki' },
es: { title: 'ES 查询', description: '对接自建 Elasticsearch · 索引日志检索', engine: 'elasticsearch' },
datasources: { title: '日志库', description: '管理日志数据源连接(Loki / Elasticsearch)' },
alerts: { title: '日志告警', description: '基于查询条件的日志告警策略' },
};
const VALID_TABS = ['loki', 'es', 'datasources', 'alerts'];
export default function LogCenter() {
const { section } = useParams<{ section: string }>();
const tab: LogCenterTab | null =
section && ['loki', 'es', 'alerts'].includes(section) ? (section as LogCenterTab) : null;
const tab = section && VALID_TABS.includes(section) ? section : null;
const [alertReloadKey, setAlertReloadKey] = useState(0);
if (!tab) return <Navigate to="/log-center/loki" replace />;
@@ -30,8 +30,8 @@ export default function LogCenter() {
<LogQueryProvider>
<div className="space-y-4 pb-20">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-cyan-500/20 to-blue-500/20 flex items-center justify-center border border-cyan-500/20">
<ScrollText className="h-5 w-5 text-cyan-400" />
<div className="h-10 w-10 rounded-xl bg-[var(--dg-bg-tertiary)] flex items-center justify-center border border-[var(--dg-border)]">
<ScrollText className="h-5 w-5 text-[var(--dg-text-secondary)]" />
</div>
<div>
<h1 className="text-xl font-bold text-[var(--dg-text-primary)]">{meta.title}</h1>
@@ -39,16 +39,12 @@ export default function LogCenter() {
</div>
</div>
{tab !== 'alerts' && <LogCenterArchBanner />}
{tab === 'loki' && <LokiQueryPanel />}
{tab === 'es' && <EsQueryPanel />}
{tab === 'alerts' && <LogAlertsPanel key={alertReloadKey} />}
{tab !== 'alerts' && (
<LogDingAssistant onRuleCreated={() => setAlertReloadKey(k => k + 1)} />
{(tab === 'loki' || tab === 'es') && meta.engine && (
<UnifiedQueryPanel engine={meta.engine} onRuleCreated={() => setAlertReloadKey(k => k + 1)} />
)}
{tab === 'datasources' && <LogLibraryPage />}
{tab === 'alerts' && <LogAlertsPanel key={alertReloadKey} />}
</div>
</LogQueryProvider>
);
}
}
@@ -0,0 +1,108 @@
// LogClusterPanel.tsx — 日志聚类面板 // Logan.Li
import { useState } from 'react';
import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
import type { LogCluster } from './types-v3';
interface Props {
clusters: LogCluster[];
}
function PatternHighlight({ pattern }: { pattern: string }) {
// 将 * 高亮显示 // Logan.Li
const parts = pattern.split('*');
return (
<span className="text-xs font-mono">
{parts.map((part, i) => (
<span key={i}>
{part}
{i < parts.length - 1 && <span className="px-1 rounded" style={{ background: 'rgba(245,158,11,0.2)', color: '#f59e0b', fontWeight: 600 }}>*</span>}
</span>
))}
</span>
);
}
export function LogClusterPanel({ clusters }: Props) {
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [copied, setCopied] = useState<number | null>(null);
const toggleExpand = (i: number) => setExpanded(prev => ({ ...prev, [i]: !prev[i] }));
const copySample = (i: number, text: string) => {
navigator.clipboard.writeText(text);
setCopied(i);
setTimeout(() => setCopied(null), 1500);
};
const total = clusters.reduce((sum, c) => sum + c.count, 0);
return (
<div className="border rounded-lg overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2" style={{ background: 'var(--dg-bg-tertiary)', borderBottom: '1px solid var(--dg-border)' }}>
<span className="text-xs" style={{ color: 'var(--dg-text-muted)' }}>共 {clusters.length} 个聚类模式,总计 {total.toLocaleString()} 条日志</span>
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>基于日志相似度自动聚类</span>
</div>
{/* 聚类列表 */}
<div>
{clusters.map((cluster, i) => (
<div key={i} style={{ borderBottom: '1px solid var(--dg-border)' }}>
<div
className="flex items-center gap-3 px-4 py-2.5 cursor-pointer hover:opacity-90 transition-colors"
style={{ background: 'var(--dg-bg-secondary)' }}
onClick={() => toggleExpand(i)}
>
<span className="flex-shrink-0" style={{ color: 'var(--dg-text-muted)' }}>
{expanded[i] ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</span>
{/* 模式 */}
<div className="flex-1 min-w-0">
<PatternHighlight pattern={cluster.pattern} />
</div>
{/* 数量 */}
<span className="text-xs font-medium flex-shrink-0" style={{ color: 'var(--dg-text-primary)' }}>
{cluster.count.toLocaleString()}
</span>
{/* 百分比 */}
<div className="flex-shrink-0 w-[80px]">
<div className="flex items-center gap-1.5">
<div className="flex-1 h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--dg-bg-tertiary)' }}>
<div className="h-full rounded-full" style={{ width: `${cluster.percentage}%`, background: 'var(--dg-blue)' }} />
</div>
<span className="text-[10px] flex-shrink-0" style={{ color: 'var(--dg-text-muted)' }}>{cluster.percentage.toFixed(1)}%</span>
</div>
</div>
{/* 复制 */}
<button
onClick={e => { e.stopPropagation(); copySample(i, cluster.sample); }}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ color: 'var(--dg-text-muted)' }}
>
{copied === i ? <Check className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />}
</button>
</div>
{/* 展开详情 */}
{expanded[i] && (
<div className="px-4 py-3 animate-fade-in" style={{ background: 'var(--dg-bg-primary)', paddingLeft: '48px' }}>
<p className="text-[10px] mb-1" style={{ color: 'var(--dg-text-muted)' }}>样例日志:</p>
<div className="p-2 rounded text-[11px] font-mono break-all" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
{cluster.sample}
</div>
<div className="flex gap-4 mt-2 text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>
<span>数量:{cluster.count.toLocaleString()}</span>
<span>占比:{cluster.percentage.toFixed(2)}%</span>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,202 @@
// LogDatasourcePage — 日志库数据源管理(Loki / Elasticsearch CRUD)// Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
Plus, Pencil, Trash2, RefreshCw, Loader2, Star, Plug,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { logCenterApi, type LogDataSource } from '@/api/monitor/log-center';
import { LogDatasourceDialog } from './LogDatasourceDialog';
import type { LogEngine } from './log-center-types';
export function LogDatasourcePage() {
const { toast } = useToast();
const confirm = useConfirm();
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,
});
const load = useCallback(async () => {
setLoading(true);
try {
const res = await logCenterApi.listDatasources();
setSources(Array.isArray(res) ? res : []);
} catch {
setSources([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleAdd = (engine: LogEngine) => {
setDialog({ open: true, engine, editing: null });
};
const handleEdit = (ds: LogDataSource) => {
setDialog({ open: true, engine: ds.type, editing: ds });
};
const handleDelete = async (ds: LogDataSource) => {
const ok = await confirm({
title: '删除数据源',
description: `确认删除「${ds.name}」?`,
variant: 'destructive',
confirmText: '删除',
});
if (!ok) return;
try {
await logCenterApi.deleteDatasource(ds.id);
toast({ title: '已删除' });
load();
} catch (e: any) {
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
}
};
const handleTest = async (ds: LogDataSource) => {
try {
await logCenterApi.testDatasource({
type: ds.type,
url: ds.url,
basic_auth: ds.basic_auth,
basic_auth_user: ds.basic_auth_user,
basic_auth_pass: '',
skip_tls_verify: ds.skip_tls_verify,
});
toast({ title: '连接成功', description: `${ds.name} 可达` });
} catch (e: any) {
toast({ title: '连接失败', description: e?.message || String(e), variant: 'destructive' });
}
};
const handleSetDefault = async (ds: LogDataSource) => {
try {
await logCenterApi.setDefaultDatasource(ds.id);
toast({ title: '已设为默认' });
load();
} catch (e: any) {
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
}
};
const engineLabel = (t: string) => t === 'loki' ? 'Loki' : 'Elasticsearch';
const engineBadge = (t: string) => t === 'loki'
? 'bg-orange-500/15 text-orange-400 border-orange-500/25'
: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/25';
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm text-[var(--dg-text-muted)]">
日志查询依赖数据源连接,平台不存储海量日志本体
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-9 border-[var(--dg-border)]" onClick={load}>
<RefreshCw className="h-4 w-4 mr-1" />刷新
</Button>
<Button size="sm" className="h-9" onClick={() => handleAdd('loki')}>
<Plus className="h-4 w-4 mr-1" />Loki
</Button>
<Button size="sm" className="h-9" onClick={() => handleAdd('elasticsearch')}>
<Plus className="h-4 w-4 mr-1" />Elasticsearch
</Button>
</div>
</div>
{/* Table */}
<Card className="dg-card">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">类型</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">地址</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">默认</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-[var(--dg-text-muted)] py-12">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...
</TableCell>
</TableRow>
) : sources.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-[var(--dg-text-muted)] py-12">
暂无数据源,点击上方按钮添加
</TableCell>
</TableRow>
) : (
sources.map((ds) => (
<TableRow key={ds.id} className="border-[var(--dg-border)]">
<TableCell className="font-medium text-[var(--dg-text-primary)]">
{ds.name}
</TableCell>
<TableCell>
<Badge className={engineBadge(ds.type)}>{engineLabel(ds.type)}</Badge>
</TableCell>
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">
{ds.url}
</TableCell>
<TableCell>
{ds.is_default
? <Star className="h-4 w-4 text-amber-400" />
: <span className="text-xs text-[var(--dg-text-muted)]">-</span>}
</TableCell>
<TableCell>
<div className="flex items-center 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="测试连接" 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="设为默认" 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="编辑" 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="删除" onClick={() => handleDelete(ds)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Add/Edit Dialog */}
<LogDatasourceDialog
open={dialog.open}
onOpenChange={(v) => setDialog({ ...dialog, open: v })}
engine={dialog.engine}
editing={dialog.editing}
onSaved={load}
/>
</div>
);
}
@@ -0,0 +1,143 @@
// LogFieldSidebar.tsx — 左侧字段面板 // Logan.Li
import { useState, useMemo } from 'react';
import { Search, ChevronDown, ChevronRight } from 'lucide-react';
import type { LogField } from './types-v3';
interface LogFieldSidebarProps {
fields: LogField[];
indexedFields?: LogField[];
selectedFields?: string[];
onFieldClick?: (field: string) => void;
onToggleDisplayField?: (field: string) => void;
loading?: boolean;
}
const TYPE_DOT: Record<string, string> = {
text: '#3b82f6', keyword: '#22c55e', long: '#22c55e',
date: '#a855f7', ip: '#f97316', boolean: '#f59e0b',
};
function FieldDot({ type }: { type?: string }) {
return (
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ background: TYPE_DOT[type || 'text'] || '#3b82f6' }}
/>
);
}
export function LogFieldSidebar({
fields, indexedFields, selectedFields = [],
onFieldClick, onToggleDisplayField, loading,
}: LogFieldSidebarProps) {
const [search, setSearch] = useState('');
const [showDisplay, setShowDisplay] = useState(true);
const [showIndexed, setShowIndexed] = useState(false);
const filteredFields = useMemo(() => {
if (!search) return fields;
return fields.filter(f => f.name.toLowerCase().includes(search.toLowerCase()));
}, [fields, search]);
const filteredIndexed = useMemo(() => {
const list = indexedFields || [];
if (!search) return list;
return list.filter(f => f.name.toLowerCase().includes(search.toLowerCase()));
}, [indexedFields, search]);
if (loading) {
return (
<div className="w-[200px] flex-shrink-0 border-r pr-2 animate-pulse" style={{ borderColor: 'var(--dg-border)' }}>
<div className="h-8 rounded mb-2" style={{ background: 'var(--dg-bg-tertiary)' }} />
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-6 rounded mb-1" style={{ background: 'var(--dg-bg-tertiary)' }} />
))}
</div>
);
}
return (
<div className="w-[200px] flex-shrink-0 border-r pr-2 flex flex-col" style={{ borderColor: 'var(--dg-border)' }}>
{/* 搜索 */}
<div className="relative mb-2">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3" style={{ color: 'var(--dg-text-muted)' }} />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="搜索字段"
className="w-full h-7 pl-7 pr-2 text-xs rounded-md dg-input"
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}
/>
</div>
{/* 显示字段 */}
<div className="mb-1">
<button
onClick={() => setShowDisplay(!showDisplay)}
className="flex items-center gap-1 w-full py-1 text-[10px] font-medium uppercase"
style={{ color: 'var(--dg-text-muted)' }}
>
{showDisplay ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
显示字段
<span className="ml-auto text-[9px] px-1 rounded" style={{ background: 'var(--dg-bg-tertiary)' }}>
{fields.length}
</span>
</button>
{showDisplay && (
<div className="space-y-0.5 mt-1">
{filteredFields.map(f => (
<div
key={f.name}
className="flex items-center gap-1.5 py-1 px-1.5 rounded cursor-pointer hover:opacity-80 transition-colors"
style={{ background: selectedFields.includes(f.name) ? 'var(--dg-bg-tertiary)' : 'transparent' }}
onClick={() => onFieldClick?.(f.name)}
title={`${f.name} (${f.type || 'text'})${f.count ? ` · ${f.count} 个值` : ''}`}
>
<input
type="checkbox"
className="h-3 w-3 rounded"
checked={selectedFields.includes(f.name)}
onChange={e => { e.stopPropagation(); onToggleDisplayField?.(f.name); }}
/>
<FieldDot type={f.type} />
<span className="text-xs truncate flex-1" style={{ color: 'var(--dg-text-secondary)' }}>{f.name}</span>
</div>
))}
</div>
)}
</div>
{/* 索引字段 */}
{filteredIndexed.length > 0 && (
<div>
<button
onClick={() => setShowIndexed(!showIndexed)}
className="flex items-center gap-1 w-full py-1 text-[10px] font-medium uppercase"
style={{ color: 'var(--dg-text-muted)' }}
>
{showIndexed ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
索引字段
<span className="ml-auto text-[9px] px-1 rounded" style={{ background: 'var(--dg-bg-tertiary)' }}>
{filteredIndexed.length}
</span>
</button>
{showIndexed && (
<div className="space-y-0.5 mt-1">
{filteredIndexed.map(f => (
<div
key={f.name}
className="flex items-center gap-1.5 py-1 px-1.5 rounded cursor-pointer hover:opacity-80 transition-colors"
onClick={() => onFieldClick?.(f.name)}
title={`${f.name} (${f.type || 'text'})`}
>
<FieldDot type={f.type} />
<span className="text-xs truncate" style={{ color: 'var(--dg-text-secondary)' }}>{f.name}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,142 @@
// LogHistogram.tsx — Log Volume 时间分布面积图 // Logan.Li
import { useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import type { LogEngine } from './types-v3';
interface LogHistogramProps {
data: { timestamp: string; count: number }[];
engine: LogEngine;
onBarClick?: (start: string, end: string) => void;
loading?: boolean;
totalBytes?: number; // 新增:处理字节数 // Logan.Li
lineLimitReached?: boolean; // 新增:是否达到行限制
coverage?: string; // 新增:时间覆盖率
}
const ENGINE_COLORS: Record<LogEngine, { fill: string; stroke: string }> = {
loki: { fill: 'url(#gradientLoki)', stroke: '#06b6d4' },
elasticsearch: { fill: 'url(#gradientES)', stroke: '#22c55e' },
sls: { fill: 'url(#gradientSLS)', stroke: '#f97316' },
};
function formatTimeLabel(ts: string): string {
const parts = ts.split(':');
return parts.length >= 2 ? `${parts[0].slice(-2)}:${parts[1]}` : ts.slice(-5);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
export function LogHistogram({ data, engine, onBarClick, loading, totalBytes, lineLimitReached, coverage }: LogHistogramProps) {
const colors = ENGINE_COLORS[engine];
const chartData = useMemo(() => {
return data.map(d => ({ ...d, label: formatTimeLabel(d.timestamp) }));
}, [data]);
if (loading) {
return (
<div className="rounded-lg border animate-pulse" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="h-[140px] flex items-center justify-center">
<span className="text-xs" style={{ color: 'var(--dg-text-muted)' }}>Loading log volume...</span>
</div>
</div>
);
}
if (chartData.length === 0) {
return (
<div className="rounded-lg border flex items-center justify-center" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)', minHeight: '100px' }}>
<span className="text-xs" style={{ color: 'var(--dg-text-muted)' }}>Enter a query to display volume</span>
</div>
);
}
return (
<div>
{/* 统计提示行 */}
{(lineLimitReached || coverage || totalBytes) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mb-2 text-[11px]" style={{ color: 'var(--dg-text-muted)' }}>
{lineLimitReached && (
<span>
<span style={{ color: '#f59e0b' }}>Line limit reached</span>
{coverage && `, received logs cover ${coverage}`}
</span>
)}
{totalBytes !== undefined && (
<span>Total bytes processed: <span className="font-medium" style={{ color: 'var(--dg-text-secondary)' }}>{formatBytes(totalBytes)}</span></span>
)}
</div>
)}
<div className="h-[140px] rounded-lg border" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="gradientLoki" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#06b6d4" stopOpacity={0.3} />
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0.02} />
</linearGradient>
<linearGradient id="gradientES" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.02} />
</linearGradient>
<linearGradient id="gradientSLS" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f97316" stopOpacity={0.3} />
<stop offset="100%" stopColor="#f97316" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" vertical={false} />
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }}
axisLine={{ stroke: 'var(--dg-border)' }}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }}
axisLine={false}
tickLine={false}
width={35}
/>
<Tooltip
contentStyle={{
background: 'var(--dg-bg-secondary)',
border: '1px solid var(--dg-border)',
borderRadius: '6px',
fontSize: '11px',
color: 'var(--dg-text-primary)',
}}
formatter={(value: number) => [`${value} 条`, 'logs']}
labelFormatter={(label: string) => {
const item = chartData.find(d => d.label === label);
return item ? item.timestamp : label;
}}
/>
<Area
type="monotone"
dataKey="count"
stroke={colors.stroke}
fill={colors.fill}
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, stroke: colors.stroke, strokeWidth: 1 }}
onClick={(entry: any) => {
if (onBarClick && entry?.timestamp) {
const start = entry.timestamp;
const end = start.slice(0, 13) + ':59:59';
onBarClick(start, end);
}
}}
style={{ cursor: onBarClick ? 'pointer' : 'default' }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}
@@ -0,0 +1,269 @@
// 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>
);
}
@@ -0,0 +1,174 @@
// LogQueryToolbar.tsx — 查询工具栏(统计条 + 分页 + 操作)// Logan.Li
import { ArrowUpDown, Bell, Download, Copy, ChevronLeft, ChevronRight } from 'lucide-react';
import { useState } from 'react';
interface Props {
total: number;
queryTime?: number;
status?: 'precise' | 'partial' | 'failed';
perPage: number;
onPerPageChange: (n: number) => void;
page: number;
totalPages: number;
onPageChange: (p: number) => void;
sortDirection?: 'asc' | 'desc';
onSortToggle?: () => void;
onExport?: () => void;
onCopy?: () => void;
onBellClick?: () => void;
hasAlertRule?: boolean;
}
const STATUS_LABEL: Record<string, { text: string; color: string }> = {
precise: { text: '结果精确', color: '#22c55e' },
partial: { text: '部分命中', color: '#f59e0b' },
failed: { text: '查询失败', color: '#ef4444' },
};
export function LogQueryToolbar({
total, queryTime, status = 'precise', perPage, onPerPageChange,
page, totalPages, onPageChange, sortDirection, onSortToggle,
onExport, onCopy, onBellClick, hasAlertRule,
}: Props) {
const [jumpPage, setJumpPage] = useState('');
const st = STATUS_LABEL[status] || STATUS_LABEL.precise;
const handleJump = () => {
const p = parseInt(jumpPage, 10);
if (p >= 1 && p <= totalPages) onPageChange(p);
setJumpPage('');
};
return (
<div className="flex flex-wrap items-center gap-3 py-2 px-3 border rounded-lg" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
{/* 左侧统计 */}
<div className="flex items-center gap-3">
<span className="text-base font-semibold" style={{ color: 'var(--dg-text-primary)' }}>
日志条数: {total.toLocaleString()}
</span>
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{ backgroundColor: `${st.color}15`, color: st.color }}
>
{st.text}
</span>
{queryTime !== undefined && (
<span className="text-xs" style={{ color: 'var(--dg-text-muted)' }}>耗时: {queryTime}ms</span>
)}
</div>
{/* 分页 */}
<div className="flex items-center gap-1.5 ml-auto">
<select
value={perPage}
onChange={e => onPerPageChange(Number(e.target.value))}
className="h-7 px-1.5 text-[11px] rounded dg-input"
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}
>
{[10, 20, 50, 100, 200].map(n => <option key={n} value={n}>每页 {n}</option>)}
</select>
<span className="text-[11px] mx-1" style={{ color: 'var(--dg-text-muted)' }}>
总数: {total.toLocaleString()} 条
</span>
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="h-7 w-7 flex items-center justify-center rounded border disabled:opacity-30 transition-colors"
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
{/* 页码 */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let p: number;
if (totalPages <= 5) p = i + 1;
else if (page <= 3) p = i + 1;
else if (page >= totalPages - 2) p = totalPages - 4 + i;
else p = page - 2 + i;
return (
<button
key={p}
onClick={() => onPageChange(p)}
className="h-7 min-w-[28px] px-1.5 text-[11px] rounded border transition-colors"
style={{
borderColor: p === page ? 'var(--dg-blue)' : 'var(--dg-border)',
background: p === page ? 'var(--dg-blue)' : 'var(--dg-bg-tertiary)',
color: p === page ? '#fff' : 'var(--dg-text-secondary)',
}}
>
{p}
</button>
);
})}
{totalPages > 5 && page < totalPages - 2 && (
<span className="text-[11px] px-1" style={{ color: 'var(--dg-text-muted)' }}>...</span>
)}
<button
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
disabled={page >= totalPages}
className="h-7 w-7 flex items-center justify-center rounded border disabled:opacity-30 transition-colors"
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
<div className="flex items-center gap-1 ml-1">
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>到第</span>
<input
type="number"
value={jumpPage}
onChange={e => setJumpPage(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleJump()}
className="h-7 w-10 text-center text-[11px] rounded dg-input"
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}
/>
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>页</span>
</div>
</div>
{/* 右侧操作 */}
<div className="flex items-center gap-1">
<button
onClick={onSortToggle}
className="h-7 w-7 flex items-center justify-center rounded border transition-colors"
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
title={sortDirection === 'desc' ? '降序' : '升序'}
>
<ArrowUpDown className="h-3.5 w-3.5" />
</button>
<button
onClick={onExport}
className="h-7 w-7 flex items-center justify-center rounded border transition-colors"
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
title="导出"
>
<Download className="h-3.5 w-3.5" />
</button>
<button
onClick={onCopy}
className="h-7 w-7 flex items-center justify-center rounded border transition-colors"
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
title="复制"
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
onClick={onBellClick}
className="relative h-7 w-7 flex items-center justify-center rounded border transition-colors"
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
title="关联告警"
>
<Bell className="h-3.5 w-3.5" />
{hasAlertRule && (
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-red-500 ring-1" style={{ outline: '2px solid var(--dg-bg-secondary)' }} />
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,172 @@
// QueryHistoryPanel.tsx — 查询历史面板 // Logan.Li
import { X, Trash2, Play, Flame, Database, Cloud, Clock } from 'lucide-react';
import { useState, useEffect } from 'react';
import type { QueryHistoryItem } from './types-v3';
import { historyStorage } from './types-v3';
import { LogAlertBell } from './LogAlertBell';
interface Props {
visible: boolean;
onClose: () => void;
onSelect: (item: QueryHistoryItem) => void;
onCreateAlert: (item: QueryHistoryItem) => void;
}
const ENGINE_ICONS: Record<string, { Icon: typeof Flame; color: string }> = {
loki: { Icon: Flame, color: '#06b6d4' },
elasticsearch: { Icon: Database, color: '#22c55e' },
sls: { Icon: Cloud, color: '#f97316' },
};
function relativeTime(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
return `${Math.floor(diff / 86400000)}天前`;
}
export function QueryHistoryPanel({ visible, onClose, onSelect, onCreateAlert }: Props) {
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
useEffect(() => {
if (visible) setHistory(historyStorage.getAll());
}, [visible]);
const handleClear = () => {
historyStorage.clear();
setHistory([]);
};
const handleDelete = (id: string) => {
historyStorage.remove(id);
setHistory(historyStorage.getAll());
};
if (!visible) return null;
return (
<>
{/* 遮罩 */}
<div className="fixed inset-0 z-40" style={{ background: 'rgba(0,0,0,0.3)' }} onClick={onClose} />
{/* 面板 */}
<div
className="fixed right-0 top-0 h-full z-50 border-l flex flex-col animate-fade-in"
style={{ width: '380px', background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}
>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" style={{ color: 'var(--dg-text-muted)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>查询历史</span>
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-muted)' }}>
{history.length} 条
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleClear}
className="h-7 w-7 flex items-center justify-center rounded transition-colors"
style={{ color: 'var(--dg-text-muted)' }}
title="清空"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
onClick={onClose}
className="h-7 w-7 flex items-center justify-center rounded transition-colors"
style={{ color: 'var(--dg-text-muted)' }}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 列表 */}
<div className="flex-1 overflow-y-auto">
{history.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-2">
<Clock className="h-8 w-8" style={{ color: 'var(--dg-border)' }} />
<span className="text-xs" style={{ color: 'var(--dg-text-muted)' }}>暂无查询记录</span>
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>执行查询后将自动记录</span>
</div>
) : (
<div className="divide-y" style={{ borderColor: 'var(--dg-border)' }}>
{history.map(item => {
const { Icon, color } = ENGINE_ICONS[item.engine] || ENGINE_ICONS.loki;
return (
<div
key={item.id}
className="p-3 hover:opacity-90 transition-colors group"
style={{ background: 'var(--dg-bg-secondary)' }}
>
{/* 引擎 + 数据源 + 时间 */}
<div className="flex items-center gap-2 mb-1.5">
<span
className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{ background: `${color}15`, color }}
>
<Icon className="h-3 w-3" />
{item.engine === 'elasticsearch' ? 'ES' : item.engine.toUpperCase()}
</span>
<span className="text-[11px] truncate flex-1" style={{ color: 'var(--dg-text-secondary)' }}>{item.datasourceName}</span>
<span className="text-[10px] flex-shrink-0" style={{ color: 'var(--dg-text-muted)' }}>{relativeTime(item.timestamp)}</span>
</div>
{/* 查询语句 */}
<div
className="text-[11px] font-mono mb-1.5 p-1.5 rounded truncate cursor-pointer"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}
onClick={() => onSelect(item)}
title={item.query}
>
{item.query}
</div>
{/* 底部信息 + 操作 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>{item.timeRange}</span>
{item.resultCount !== undefined && (
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>{item.resultCount} 条</span>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => onSelect(item)}
className="h-6 w-6 flex items-center justify-center rounded transition-colors"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-green)' }}
title="执行查询"
>
<Play className="h-3 w-3" />
</button>
<LogAlertBell
hasAlertRule={item.hasAlertRule}
onClick={() => onCreateAlert(item)}
size="sm"
/>
<button
onClick={() => handleDelete(item.id)}
className="h-6 w-6 flex items-center justify-center rounded transition-colors"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-muted)' }}
>
<X className="h-3 w-3" />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 底部 */}
<div className="px-4 py-2 border-t text-[10px] text-center" style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-muted)' }}>
最多保存 50 条 · 本地存储
</div>
</div>
</>
);
}
@@ -0,0 +1,64 @@
// QuickQueryHelper.tsx — 快捷查询语法助手 // Logan.Li
import { useState } from 'react';
import { ChevronDown, ChevronUp, Zap } from 'lucide-react';
import type { LogEngine } from './types-v3';
import { QUICK_TEMPLATES } from './types-v3';
interface Props {
engine: LogEngine;
onSelect: (query: string) => void;
}
const ENGINE_NAMES: Record<LogEngine, string> = {
loki: 'LogQL',
elasticsearch: 'Lucene',
sls: 'SQL/SPL',
};
export function QuickQueryHelper({ engine, onSelect }: Props) {
const [open, setOpen] = useState(false);
const templates = QUICK_TEMPLATES[engine];
return (
<div>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1 text-[11px] px-2 py-1 rounded transition-colors"
style={{ color: 'var(--dg-blue)', background: open ? 'var(--dg-bg-tertiary)' : 'transparent' }}
>
<Zap className="h-3 w-3" />
快捷查询
{open ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
{open && (
<div
className="mt-2 p-3 rounded-lg border animate-fade-in"
style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}
>
<p className="text-[10px] mb-2" style={{ color: 'var(--dg-text-muted)' }}>
{ENGINE_NAMES[engine]} 常用查询模板,点击快速填充
</p>
<div className="grid grid-cols-2 gap-1.5">
{templates.map((t, i) => (
<button
key={i}
onClick={() => { onSelect(t.query); setOpen(false); }}
className="text-left px-2 py-1.5 rounded border text-[11px] transition-all hover:border-blue-400"
style={{
background: 'var(--dg-bg-tertiary)',
borderColor: 'var(--dg-border)',
color: 'var(--dg-text-secondary)',
}}
title={t.query}
>
<span className="font-medium block truncate" style={{ color: 'var(--dg-text-primary)' }}>{t.label}</span>
<span className="block truncate opacity-60 font-mono mt-0.5">{t.query}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,126 @@
// SaveQueryDialog.tsx — 另存为查询 / 另存为告警 // Logan.Li
import { useState } from 'react';
import { X, Save, BellRing, Loader2 } from 'lucide-react';
import type { LogEngine, TimeRange } from './types-v3';
import { ENGINE_META } from './types-v3';
interface SaveQueryProps {
open: boolean;
onClose: () => void;
engine: LogEngine;
query: string;
timeRange: TimeRange;
datasourceName?: string;
onSave?: (name: string) => void;
}
export function SaveQueryDialog({ open, onClose, engine, query, timeRange, datasourceName, onSave }: SaveQueryProps) {
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
if (!open) return null;
const handleSave = () => {
if (!name.trim()) return;
setSaving(true);
setTimeout(() => { setSaving(false); setSaved(true); onSave?.(name); setTimeout(() => { setSaved(false); onClose(); }, 800); }, 500);
};
return (
<>
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={onClose} />
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-2"><Save className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>另存为查询</span></div>
<button onClick={onClose} className="h-7 w-7 flex items-center justify-center" style={{ color: 'var(--dg-text-muted)' }}><X className="h-4 w-4" /></button>
</div>
<div className="p-4 space-y-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>查询名称 *</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="输入查询名称" className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
</div>
<div className="p-2 rounded text-[11px]" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
<span style={{ color: ENGINE_META[engine].color }}>{ENGINE_META[engine].name}</span> · {datasourceName || '默认'}<br />
<span className="font-mono">{query}</span><br />
<span style={{ color: 'var(--dg-text-muted)' }}>{timeRange.label} · {timeRange.start} ~ {timeRange.end}</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
<button onClick={handleSave} disabled={saving || !name.trim() || saved} className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: 'var(--dg-blue)' }}>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已保存' : '保存'}
</button>
</div>
</div>
</>
);
}
// 另存为告警对话框 // Logan.Li
interface SaveAlertProps {
open: boolean;
onClose: () => void;
engine: LogEngine;
query: string;
datasourceName?: string;
onSave?: () => void;
}
export function SaveAlertDialog({ open, onClose, engine, query, datasourceName, onSave }: SaveAlertProps) {
const [name, setName] = useState('');
const [severity, setSeverity] = useState('warning');
const [condition, setCondition] = useState('count > 0');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
if (!open) return null;
const meta = ENGINE_META[engine];
const handleSave = () => {
if (!name.trim()) return;
setSaving(true);
setTimeout(() => { setSaving(false); setSaved(true); onSave?.(); setTimeout(() => { setSaved(false); onClose(); }, 800); }, 500);
};
return (
<>
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={onClose} />
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-2"><BellRing className="h-4 w-4" style={{ color: '#f59e0b' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>另存为告警</span></div>
<button onClick={onClose} className="h-7 w-7 flex items-center justify-center" style={{ color: 'var(--dg-text-muted)' }}><X className="h-4 w-4" /></button>
</div>
<div className="p-4 space-y-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>告警名称 *</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="输入告警规则名称" className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</label>
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full h-9 px-2 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
<option value="info">提示</option><option value="warning">警告</option><option value="critical">严重</option>
</select>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件</label>
<input value={condition} onChange={e => setCondition(e.target.value)} className="w-full h-9 px-2 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
</div>
</div>
<div className="p-2 rounded text-[11px]" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
<span style={{ color: meta.color }}>{meta.name}</span> · {datasourceName || '默认'}<br />
<span className="font-mono">{query}</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
<button onClick={handleSave} disabled={saving || !name.trim() || saved} className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: '#f59e0b' }}>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已创建' : '创建告警'}
</button>
</div>
</div>
</>
);
}
@@ -0,0 +1,283 @@
// StatChartPanel.tsx — 统计图表面板(SLS风格)// Logan.Li
import { useState, useMemo } from 'react';
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { Save, Play, AlertCircle } from 'lucide-react';
import type { StatTable, ChartType, LogEngine } from './types-v3';
import { ENGINE_META } from './types-v3';
interface Props {
statTable?: StatTable;
engine: LogEngine;
query: string;
onSaveAsAlert?: () => void;
onSaveAsQuery?: () => void;
}
const CHART_TYPE_LABELS: Record<ChartType, string> = {
table: '表格',
line: '线图',
bar: '柱状图',
pie: '饼图',
area: '面积图',
};
const PIE_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#a855f7', '#06b6d4', '#f97316', '#84cc16'];
export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSaveAsQuery }: Props) {
const [chartType, setChartType] = useState<ChartType>('table');
const [xAxisField, setXAxisField] = useState('');
const [yAxisField, setYAxisField] = useState('');
const [sampleQuery, setSampleQuery] = useState('');
const meta = ENGINE_META[engine];
// 自动检测字段 // Logan.Li
const columns = statTable?.columns || [];
const rows = statTable?.rows || [];
const stringCols = columns.filter(c => c.type === 'string' || c.type === 'time').map(c => c.name);
const numCols = columns.filter(c => c.type === 'number').map(c => c.name);
// 默认选字段
const defaultX = xAxisField || stringCols[0] || '';
const defaultY = yAxisField || numCols[0] || '';
// 生成查询样例
const querySamples = useMemo(() => {
const samples: Record<LogEngine, string[]> = {
loki: [
'sum by(level) (count_over_time({app="service"}[5m]))',
'avg_over_time({app="service"} | json | unwrap response_time [5m])',
'topk(10, sum by(path) (count_over_time({app="nginx"}[1h])))',
],
elasticsearch: [
'SELECT level, COUNT(*) as cnt GROUP BY level',
'SELECT method, AVG(response_time) as avg_rt GROUP BY method',
'SELECT status_code, COUNT(*) as cnt GROUP BY status_code ORDER BY cnt DESC LIMIT 10',
],
sls: [
'* | SELECT level, COUNT(*) as cnt GROUP BY level',
'* | SELECT method, AVG(response_time) as avg_rt GROUP BY method',
'* | SELECT status_code, COUNT(*) as cnt GROUP BY status_code ORDER BY cnt DESC LIMIT 10',
],
};
return samples[engine];
}, [engine]);
const chartData = useMemo(() => {
if (!rows.length) return [];
return rows.map(r => {
const obj: Record<string, any> = {};
columns.forEach(c => { obj[c.name] = r[c.name]; });
return obj;
});
}, [rows, columns]);
// 判断是否有数据
if (!statTable || rows.length === 0) {
return (
<div className="border rounded-lg p-6" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" style={{ color: '#f59e0b' }} />
<span className="text-xs" style={{ color: '#f59e0b' }}>当前暂无统计信息,您可以按照样例进行操作</span>
</div>
<button
onClick={onSaveAsQuery}
className="h-7 px-3 rounded text-[11px] flex items-center gap-1"
style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}
>
<Save className="h-3 w-3" /> 另存为查询
</button>
</div>
{/* 查询样例 */}
<div className="mb-4">
<p className="text-[10px] mb-2" style={{ color: 'var(--dg-text-muted)' }}>查询样例:</p>
<div className="space-y-1">
{querySamples.map((q, i) => (
<button
key={i}
onClick={() => setSampleQuery(q)}
className="w-full text-left px-3 py-2 rounded text-[11px] font-mono transition-colors border"
style={{ background: 'var(--dg-bg-tertiary)', borderColor: 'var(--dg-border)', color: 'var(--dg-text-secondary)' }}
>
{q}
</button>
))}
</div>
</div>
{sampleQuery && (
<div className="flex gap-2">
<button className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: meta.color }}>
<Play className="h-3.5 w-3.5" /> 执行分析
</button>
</div>
)}
</div>
);
}
return (
<div className="flex gap-3">
{/* 左侧:结果表格 + 图表 */}
<div className="flex-1 min-w-0">
{/* 图表类型切换 */}
<div className="flex items-center gap-1 mb-3">
{(Object.keys(CHART_TYPE_LABELS) as ChartType[]).map(t => (
<button
key={t}
onClick={() => setChartType(t)}
className="px-2.5 py-1 rounded text-[11px] transition-colors"
style={{
background: chartType === t ? meta.color : 'var(--dg-bg-tertiary)',
color: chartType === t ? '#fff' : 'var(--dg-text-muted)',
}}
>
{CHART_TYPE_LABELS[t]}
</button>
))}
<button onClick={onSaveAsAlert} className="ml-auto h-7 px-2.5 rounded text-[11px] flex items-center gap-1" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>
<Save className="h-3 w-3" /> 另存为告警
</button>
</div>
{/* 表格视图 */}
{chartType === 'table' && (
<div className="border rounded-lg overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr style={{ background: 'var(--dg-bg-tertiary)', borderBottom: '1px solid var(--dg-border)' }}>
{columns.map(c => (
<th key={c.name} className="text-left px-3 py-2 font-medium" style={{ color: 'var(--dg-text-muted)', fontSize: '10px' }}>{c.name}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--dg-border)' }}>
{columns.map(c => (
<td key={c.name} className="px-3 py-2 font-mono" style={{ color: 'var(--dg-text-secondary)' }}>
{typeof row[c.name] === 'number' ? row[c.name].toLocaleString() : String(row[c.name])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="px-3 py-2 text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>总数:{rows.length}</div>
</div>
)}
{/* 图表视图 */}
{chartType !== 'table' && (
<div className="border rounded-lg p-3" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
<ResponsiveContainer width="100%" height={300}>
{chartType === 'line' ? (
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
<XAxis dataKey={defaultX} tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} />
<YAxis tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} />
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} />
<Line type="monotone" dataKey={defaultY} stroke={meta.color} strokeWidth={2} dot={false} />
</LineChart>
) : chartType === 'bar' ? (
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
<XAxis dataKey={defaultX} tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} />
<YAxis tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} />
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} />
<Bar dataKey={defaultY} fill={meta.color} radius={[2, 2, 0, 0]} />
</BarChart>
) : chartType === 'area' ? (
<AreaChart data={chartData}>
<defs>
<linearGradient id="statGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={meta.color} stopOpacity={0.3} />
<stop offset="100%" stopColor={meta.color} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
<XAxis dataKey={defaultX} tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} />
<YAxis tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} />
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} />
<Area type="monotone" dataKey={defaultY} stroke={meta.color} fill="url(#statGradient)" />
</AreaChart>
) : (
<PieChart>
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} />
<Pie data={chartData} dataKey={defaultY} nameKey={defaultX} cx="50%" cy="50%" outerRadius={100}>
{chartData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Legend />
</PieChart>
)}
</ResponsiveContainer>
</div>
)}
</div>
{/* 右侧:通用配置面板 */}
<div className="w-[260px] flex-shrink-0 border rounded-lg p-3" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex border-b mb-3" style={{ borderColor: 'var(--dg-border)' }}>
<button className="pb-2 text-xs font-medium" style={{ color: 'var(--dg-blue)', borderBottom: '2px solid var(--dg-blue)' }}>通用配置</button>
</div>
{/* 图表类型 */}
<div className="mb-3">
<p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>图表类型</p>
<div className="grid grid-cols-5 gap-1">
{(Object.keys(CHART_TYPE_LABELS) as ChartType[]).map(t => (
<button
key={t}
onClick={() => setChartType(t)}
className="py-1 rounded text-[9px] text-center transition-colors"
style={{
background: chartType === t ? 'var(--dg-bg-tertiary)' : 'transparent',
color: chartType === t ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)',
border: chartType === t ? '1px solid var(--dg-border)' : '1px solid transparent',
}}
>
{CHART_TYPE_LABELS[t].slice(0, 2)}
</button>
))}
</div>
</div>
{/* 字段配置 */}
<div className="mb-3">
<p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>X 轴字段</p>
<select
value={defaultX}
onChange={e => setXAxisField(e.target.value)}
className="w-full h-7 px-2 text-[11px] rounded mb-2"
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}
>
{stringCols.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>Y 轴字段</p>
<select
value={defaultY}
onChange={e => setYAxisField(e.target.value)}
className="w-full h-7 px-2 text-[11px] rounded"
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}
>
{numCols.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
{/* 当前查询 */}
<div>
<p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>当前查询</p>
<div className="p-2 rounded text-[10px] font-mono break-all" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
{query || '暂无查询'}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
// 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>
);
}
@@ -0,0 +1,141 @@
// TimeRangePicker.tsx — SLS风格时间选择器 // Logan.Li
import { useState, useRef, useEffect } from 'react';
import { ChevronDown, ChevronUp, Calendar } from 'lucide-react';
import type { TimeRange } from './types-v3';
const PRESETS = [
{ label: '1分钟', value: '1m' },
{ label: '5分钟', value: '5m' },
{ label: '15分钟', value: '15m' },
{ label: '1小时', value: '1h' },
{ label: '4小时', value: '4h' },
{ label: '1天', value: '24h' },
{ label: '今天', value: 'today' },
{ label: '昨天', value: 'yesterday' },
{ label: '前天', value: 'day_before_yesterday' },
{ label: '1周', value: '7d' },
{ label: '本周', value: 'this_week' },
{ label: '上周', value: 'last_week' },
{ label: '30天', value: '30d' },
{ label: '本月', value: 'this_month' },
{ label: '上月', value: 'last_month' },
{ label: '本季度', value: 'this_quarter' },
{ label: '本年度', value: 'this_year' },
{ label: '自定义', value: 'custom' },
];
function getPresetRange(value: string): { start: Date; end: Date; label: string } {
const now = new Date();
const start = new Date(now);
const end = new Date(now);
let label = '';
switch (value) {
case '1m': start.setMinutes(now.getMinutes() - 1); label = '1分钟'; break;
case '5m': start.setMinutes(now.getMinutes() - 5); label = '5分钟'; break;
case '15m': start.setMinutes(now.getMinutes() - 15); label = '15分钟'; break;
case '1h': start.setHours(now.getHours() - 1); label = '1小时'; break;
case '4h': start.setHours(now.getHours() - 4); label = '4小时'; break;
case '24h': start.setDate(now.getDate() - 1); label = '1天'; break;
case '7d': start.setDate(now.getDate() - 7); label = '1周'; break;
case '30d': start.setDate(now.getDate() - 30); label = '30天'; break;
case 'today': start.setHours(0, 0, 0, 0); label = '今天'; break;
case 'yesterday': start.setDate(now.getDate() - 1); start.setHours(0, 0, 0, 0); end.setDate(now.getDate() - 1); end.setHours(23, 59, 59, 999); label = '昨天'; break;
case 'day_before_yesterday': start.setDate(now.getDate() - 2); start.setHours(0, 0, 0, 0); end.setDate(now.getDate() - 2); end.setHours(23, 59, 59, 999); label = '前天'; break;
case 'this_week': { const d = now.getDay(); start.setDate(now.getDate() - d); start.setHours(0, 0, 0, 0); label = '本周'; break; }
case 'last_week': { const d = now.getDay(); start.setDate(now.getDate() - d - 7); start.setHours(0, 0, 0, 0); end.setDate(now.getDate() - d); end.setHours(0, 0, 0, 0); label = '上周'; break; }
case 'this_month': start.setDate(1); start.setHours(0, 0, 0, 0); label = '本月'; break;
case 'last_month': start.setMonth(now.getMonth() - 1); start.setDate(1); start.setHours(0, 0, 0, 0); end.setMonth(now.getMonth()); end.setDate(1); end.setHours(0, 0, 0, 0); label = '上月'; break;
case 'this_quarter': { const q = Math.floor(now.getMonth() / 3); start.setMonth(q * 3); start.setDate(1); start.setHours(0, 0, 0, 0); label = '本季度'; break; }
case 'this_year': start.setMonth(0); start.setDate(1); start.setHours(0, 0, 0, 0); label = '本年度'; break;
default: start.setHours(now.getHours() - 1); label = '1小时';
}
return { start, end, label };
}
function formatDateTime(d: Date): string {
return d.toISOString().replace('T', ' ').substring(0, 19);
}
interface Props {
value: TimeRange;
onChange: (range: TimeRange) => void;
}
export function TimeRangePicker({ value, onChange }: Props) {
const [open, setOpen] = useState(false);
const [activePreset, setActivePreset] = useState('5m');
const [roundTime, setRoundTime] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handlePreset = (presetValue: string) => {
setActivePreset(presetValue);
if (presetValue === 'custom') return;
const { start, end, label } = getPresetRange(presetValue);
onChange({ start: formatDateTime(start), end: formatDateTime(end), label, type: 'relative' });
setOpen(false);
};
return (
<div className="relative" ref={ref}>
{/* 触发按钮 */}
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 h-9 px-3 rounded border text-xs transition-colors"
style={{ background: 'var(--dg-bg-tertiary)', borderColor: 'var(--dg-border)', color: 'var(--dg-text-primary)' }}
>
<span className="font-medium" style={{ color: 'var(--dg-blue)' }}>{value.label}</span>
<span style={{ color: 'var(--dg-text-secondary)' }}>{value.start} ~ {value.end}</span>
{open ? <ChevronUp className="h-3.5 w-3.5" style={{ color: 'var(--dg-text-muted)' }} /> : <ChevronDown className="h-3.5 w-3.5" style={{ color: 'var(--dg-text-muted)' }} />}
</button>
{/* 下拉面板 */}
{open && (
<div
className="absolute top-full left-0 mt-1 z-50 rounded-lg border shadow-xl p-4"
style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)', width: '480px' }}
>
{/* 头部:时间范围显示 */}
<div className="flex items-center gap-2 mb-3 pb-3" style={{ borderBottom: '1px solid var(--dg-border)' }}>
<Calendar className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>时间选择</span>
<a className="text-xs ml-2" style={{ color: 'var(--dg-blue)' }}>日历中选择</a>
</div>
{/* 当前范围 */}
<div className="flex items-center gap-2 mb-3">
<span className="text-xs" style={{ color: 'var(--dg-text-secondary)' }}>{value.start} ~ {value.end}</span>
<label className="flex items-center gap-1 text-[10px] ml-auto" style={{ color: 'var(--dg-text-muted)' }}>
<input type="checkbox" checked={roundTime} onChange={e => setRoundTime(e.target.checked)} className="h-3 w-3 rounded" />
整点时间
</label>
</div>
{/* 预设按钮网格 */}
<div className="grid grid-cols-5 gap-1.5">
{PRESETS.map(p => (
<button
key={p.value}
onClick={() => handlePreset(p.value)}
className="py-1.5 px-1 rounded text-[11px] transition-colors"
style={{
background: activePreset === p.value ? 'var(--dg-blue)' : 'var(--dg-bg-tertiary)',
color: activePreset === p.value ? '#fff' : 'var(--dg-text-secondary)',
border: activePreset === p.value ? 'none' : '1px solid var(--dg-border)',
}}
>
{p.label}
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,360 @@
// UnifiedQueryPanel.tsx — 统一查询面板(查询体验优化版)// Logan.Li
import { useState, useMemo, useCallback, useRef } from 'react';
import { Play, Loader2, ExternalLink, Clock, Save, ChevronDown, Bell } from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { TimeRangePicker } from './TimeRangePicker';
import { LogFieldSidebar } from './LogFieldSidebar';
import { EnhancedLogResults, extractCommonLabels } from './EnhancedLogResults';
import { LogQueryToolbar } from './LogQueryToolbar';
import { StatChartPanel } from './StatChartPanel';
import { LogClusterPanel } from './LogClusterPanel';
import { SaveQueryDialog, SaveAlertDialog } from './SaveQueryDialog';
import { QueryHistoryPanel } from './QueryHistoryPanel';
import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog';
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';
// 默认最近5分钟 // Logan.Li
const DEFAULT_TIME_RANGE: TimeRange = {
start: new Date(Date.now() - 5 * 60000).toISOString().replace('T', ' ').substring(0, 19),
end: new Date().toISOString().replace('T', ' ').substring(0, 19),
label: '5分钟',
type: 'relative',
};
// Mock 数据源(Loki & ES 双引擎) // Logan.Li
const MOCK_SOURCES: Record<'loki' | 'elasticsearch', { id: number; name: string; url: string }[]> = {
loki: [
{ id: 1, name: 'prod-loki', url: 'http://loki.prod.local:3100' },
{ id: 2, name: 'dev-loki', url: 'http://loki.dev.local:3100' },
],
elasticsearch: [
{ id: 3, name: 'prod-es', url: 'http://es.prod.local:9200' },
{ id: 4, name: 'dev-es', url: 'http://es.dev.local:9200' },
],
};
// 引擎默认查询语句 // Logan.Li
const DEFAULT_QUERIES: Record<'loki' | 'elasticsearch', string> = {
loki: '{app="cex-admin-api"} |= ""',
elasticsearch: 'app:cex-admin-api',
};
// 引擎徽标:左侧小圆点 + 语言标签 // Logan.Li
const ENGINE_BADGE: Record<'loki' | 'elasticsearch', { label: string; dotColor: string }> = {
loki: { label: 'LogQL', dotColor: '#06b6d4' },
elasticsearch: { label: 'Lucene', dotColor: '#22c55e' },
};
// 增强快捷查询模板(14+ 条/引擎) // Logan.Li
const ALL_TEMPLATES: Record<'loki' | 'elasticsearch', { label: string; query: string }[]> = {
loki: [
{ label: 'Nginx 错误', query: '{job="nginx"} |= "error"' },
{ label: 'JSON 过滤', query: '{namespace="prod"} | json | level="warn"' },
{ label: '超时搜索', query: '{app="api"} |= "timeout"' },
{ label: '级别统计', query: 'sum by(level) (count_over_time({job="app"}[5m]))' },
{ label: '正则多级别', query: '{container="frontend"} |~ "ERROR|WARN|FATAL"' },
{ label: '慢请求>1s', query: '{job="api"} | json | response_time > 1000' },
{ label: '错误率统计', query: 'sum by(level) (count_over_time({app="cex-admin-api"} |= "error" [5m]))' },
{ label: '追踪ID查询', query: '{app="cex-admin-api"} |= "trace_id" | json' },
{ label: '服务聚合', query: 'sum by(service_name) (count_over_time({namespace="astralx"}[5m]))' },
{ label: '高延迟筛选', query: '{app="cex-admin-api"} | json | response_time > 500' },
{ label: '异常堆栈', query: '{app="cex-admin-api"} |= "Exception"' },
{ label: '日志趋势', query: 'sum(count_over_time({app="cex-admin-api"}[1h]))' },
{ label: '正则提取', query: '{app="cex-admin-api"} | regexp "(?P<method>\\w+) (?P<path>\\S+)"' },
{ label: '格式输出', query: '{app="cex-admin-api"} | json | line_format "{{.level}} {{.message}}"' },
],
elasticsearch: [
{ label: 'API 5xx', query: 'status:500 AND path:"/api/*"' },
{ label: '错误超时', query: 'level:error AND message:*timeout*' },
{ label: '排除调试', query: 'host.name:server-* AND NOT level:debug' },
{ label: '慢请求', query: 'response_time:>1000 AND method:POST' },
{ label: '严重错误', query: '@timestamp:[now-1h TO now] AND level:critical' },
{ label: '服务错误', query: 'service:nginx AND (level:error OR level:fatal)' },
{ label: '组合查询', query: 'app:cex-admin-api AND (level:error OR level:warn)' },
{ label: '模糊匹配', query: 'message:*timeout* AND app:cex-admin-api' },
{ label: '范围查询', query: 'response_time:[1000 TO *] AND method:POST' },
{ label: '排除路径', query: 'app:cex-admin-api AND NOT path:/health' },
{ label: '时间过滤', query: '@timestamp:[now-1h TO now] AND level:error' },
{ label: 'IP段过滤', query: 'client_ip:172.36.* AND app:cex-admin-api' },
{ label: '聚合统计', query: 'app:cex-admin-api | STATS count(*) by level' },
{ label: '嵌套字段', query: 'labels.orgId:10001 AND app:cex-admin-api' },
],
};
// LogQL / Lucene 语法高亮 → HTML // Logan.Li
function highlightSyntax(query: string, engine: 'loki' | 'elasticsearch'): string {
if (!query) return '<span style="color:transparent">.</span>';
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>'; // 闭合
}
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>';
}
// 语法高亮输入框:底层高亮 + 上层透明输入 // Logan.Li
function SyntaxHighlightInput({ value, onChange, engine, placeholder }: {
value: string; onChange: (v: string) => void; engine: 'loki' | 'elasticsearch'; placeholder?: string;
}) {
const taRef = useRef<HTMLTextAreaElement>(null);
const preRef = useRef<HTMLPreElement>(null);
const sync = useCallback(() => {
if (preRef.current && taRef.current) { preRef.current.scrollLeft = taRef.current.scrollLeft; preRef.current.scrollTop = taRef.current.scrollTop; }
}, []);
const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') { e.preventDefault(); const t = e.currentTarget, s = t.selectionStart, ed = t.selectionEnd; onChange(value.slice(0, s) + ' ' + value.slice(ed)); requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 2; }); }
};
const html = useMemo(() => highlightSyntax(value, engine), [value, engine]);
return (
<div className="relative flex-1 min-w-[120px]" style={{ height: '40px' }}>
<pre ref={preRef} className="absolute inset-0 m-0 font-mono text-xs rounded pointer-events-none overflow-hidden whitespace-pre-wrap break-all px-3 py-2.5" style={{ background: 'transparent', border: '1px solid transparent', lineHeight: '20px', color: 'var(--dg-text-primary)', zIndex: 1 }} dangerouslySetInnerHTML={{ __html: html }} />
<textarea ref={taRef} value={value} onChange={e => onChange(e.target.value)} onScroll={sync} onKeyDown={onKey} spellCheck={false} autoComplete="off" rows={1}
className="absolute inset-0 w-full h-full font-mono text-xs rounded resize-none outline-none overflow-auto whitespace-pre-wrap break-all px-3 py-2.5"
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'transparent', caretColor: 'var(--dg-text-primary)', lineHeight: '20px', zIndex: 2 }}
placeholder={placeholder} />
{!value && <div className="absolute inset-0 flex items-center px-3 pointer-events-none" style={{ zIndex: 1 }}><span className="text-xs font-mono" style={{ color: 'var(--dg-text-muted)' }}>{placeholder}</span></div>}
</div>
);
}
interface Props { engine: LogEngine; onRuleCreated: () => void; }
export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
// SLS 回退到 Loki,仅支持双引擎 // Logan.Li
const effEngine: 'loki' | 'elasticsearch' = engine === 'sls' ? 'loki' : engine;
const meta = ENGINE_META[effEngine];
const badge = ENGINE_BADGE[effEngine];
const sources = MOCK_SOURCES[effEngine];
const [query, setQuery] = useState(DEFAULT_QUERIES[effEngine]);
const [dsId, setDsId] = useState(String(sources[0].id));
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
const [loading, setLoading] = useState(false);
const [entries, setEntries] = useState<LogEntry[]>([]);
const [statTable, setStatTable] = useState<StatTable | undefined>();
const [clusters, setClusters] = useState<LogCluster[]>([]);
const [histogramData, setHistogramData] = useState<{ timestamp: string; count: number }[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [queryTime, setQueryTime] = useState(0);
const [resultTab, setResultTab] = useState<ResultTab>('logs');
const [viewMode, setViewMode] = useState<ViewMode>('raw');
const [wrapLines, setWrapLines] = useState(false);
const [truncate, setTruncate] = useState(true);
const [selectedFields, setSelectedFields] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [saveQueryOpen, setSaveQueryOpen] = useState(false);
const [saveAlertOpen, setSaveAlertOpen] = useState(false);
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
const [alertDialogOpen, setAlertDialogOpen] = useState(false);
const [saveMenuOpen, setSaveMenuOpen] = useState(false);
const currentSource = sources.find(s => String(s.id) === dsId);
const fields = useMemo(() => extractFields(entries), [entries]);
const commonLabels = useMemo(() => extractCommonLabels(entries), [entries]);
const sortedEntries = useMemo(() => { const a = [...entries]; return sortDirection === 'asc' ? a.sort((x, y) => x.timestamp.localeCompare(y.timestamp)) : a.sort((x, y) => y.timestamp.localeCompare(x.timestamp)); }, [entries, sortDirection]);
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]);
// 执行查询 // Logan.Li
const handleRun = useCallback(() => {
setLoading(true);
const t0 = performance.now();
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);
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource?.name || 'default', query, timeRange: timeRange.label, resultCount: mock.length });
}, 600 + Math.random() * 500);
}, [effEngine, query, currentSource, timeRange]);
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); };
// 点击面积图 → 缩小时间范围到该区域 // Logan.Li
const handleChartClick = useCallback((st: any) => {
if (!st?.activeLabel) return;
const idx = histogramData.findIndex(d => d.timestamp === st.activeLabel);
if (idx < 0) return;
const sIdx = Math.max(0, idx - 1), eIdx = Math.min(histogramData.length - 1, idx + 1);
setTimeRange(p => ({ ...p, start: histogramData[sIdx].timestamp, end: histogramData[eIdx].timestamp, label: '自定义', type: 'absolute' as const }));
}, [histogramData]);
return (
<div className="space-y-3">
{/* ===== 查询控制行:输入框前置 & 语法高亮 & 引擎徽标 ===== */}
<div className="flex items-center gap-2">
{/* 引擎徽标 + 高亮查询输入(最醒目) */}
<div className="flex-1 flex items-center gap-2 min-w-0">
<div className="flex items-center gap-1.5 px-2.5 h-10 rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)' }}>
<span className="w-2 h-2 rounded-full" style={{ background: badge.dotColor }} />
<span className="text-[11px] font-semibold" style={{ color: badge.dotColor }}>{badge.label}</span>
</div>
<SyntaxHighlightInput value={query} onChange={setQuery} engine={effEngine} placeholder={meta.queryPlaceholder} />
</div>
<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}</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: meta.color }}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
</button>
<div className="relative flex-shrink-0">
<button onClick={() => setSaveMenuOpen(!saveMenuOpen)} className="h-10 px-3 rounded text-xs flex items-center gap-1" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}>
<Save className="h-3.5 w-3.5" /> 另存为 <ChevronDown className="h-3 w-3" />
</button>
{saveMenuOpen && (<>
<div className="fixed inset-0 z-40" onClick={() => setSaveMenuOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> 另存为查询</button>
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> 另存为告警</button>
</div>
</>)}
</div>
{currentSource?.url && (
<button onClick={() => window.open(currentSource.url, '_blank')} className="h-10 px-2.5 rounded text-xs flex items-center gap-1 flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>
{meta.externalLink} <ExternalLink className="h-3 w-3" />
</button>
)}
<button onClick={() => setHistoryPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="查询历史">
<Clock className="h-4 w-4" />
</button>
</div>
{/* ===== 快捷查询助手(内联紧凑版,14+模板) ===== */}
<QuickQueryBar engine={effEngine} templates={ALL_TEMPLATES[effEngine]} onSelect={setQuery} />
{/* ===== 统计条 + 工具栏 ===== */}
{entries.length > 0 && !loading && (
<LogQueryToolbar total={totalCount} queryTime={queryTime} perPage={perPage} onPerPageChange={n => { setPerPage(n); setPage(1); }} page={page} totalPages={totalPages} onPageChange={setPage} sortDirection={sortDirection} onSortToggle={() => setSortDirection(d => d === 'desc' ? 'asc' : 'desc')} onExport={() => { const t = sortedEntries.map(e => `${e.timestamp}\t${e.level || ''}\t${e.message}`).join('\n'); const b = new Blob([t], { type: 'text/plain' }); const u = URL.createObjectURL(b); const a = document.createElement('a'); a.href = u; a.download = `${effEngine}-logs-${Date.now()}.txt`; a.click(); URL.revokeObjectURL(u); }} onCopy={() => navigator.clipboard.writeText(pagedEntries.map(e => `${e.timestamp}\t${e.level || ''}\t${e.message}`).join('\n'))} onBellClick={() => setAlertDialogOpen(true)} />
)}
{/* ===== Log Volume 面积图(点击缩小时间范围) ===== */}
{entries.length > 0 && (
<div className="h-[120px] rounded-lg border cursor-crosshair" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={histogramData} margin={{ top: 6, right: 8, bottom: 0, left: 0 }} onClick={handleChartClick}>
<defs>
<linearGradient id={`vol-${effEngine}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={meta.color} stopOpacity={0.35} />
<stop offset="100%" stopColor={meta.color} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" vertical={false} />
<XAxis dataKey="timestamp" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={{ stroke: 'var(--dg-border)' }} tickLine={false} tickFormatter={(v: string) => v.slice(-5)} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={false} tickLine={false} width={30} />
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} formatter={(v: number) => [`${v} 条日志`, '数量']} labelFormatter={(l: string) => `时间: ${l}`} />
<Area type="monotone" dataKey="count" stroke={meta.color} fill={`url(#vol-${effEngine})`} strokeWidth={1.5} dot={false} activeDot={{ r: 4, stroke: meta.color, strokeWidth: 2, fill: 'var(--dg-bg-secondary)' }} />
</AreaChart>
</ResponsiveContainer>
</div>
)}
{/* ===== 结果标签页(紧凑内嵌样式) ===== */}
{entries.length > 0 && (
<div className="rounded-lg border" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-0.5 px-2 pt-1.5" style={{ borderBottom: '1px solid var(--dg-border)' }}>
{(['logs', 'stats', 'cluster'] as ResultTab[]).map(tab => {
const labels: Record<ResultTab, string> = { logs: '原始日志', stats: '统计图表', cluster: '日志聚类' };
return (
<button key={tab} onClick={() => setResultTab(tab)} className="px-3 py-1.5 text-[11px] font-medium rounded-t-md transition-colors"
style={{ color: resultTab === tab ? meta.color : 'var(--dg-text-muted)', background: resultTab === tab ? 'var(--dg-bg-secondary)' : 'transparent', borderBottom: resultTab === tab ? `2px solid ${meta.color}` : '2px solid transparent', marginBottom: '-1px' }}>
{labels[tab]}
</button>
);
})}
</div>
<div className="p-2">
{/* 原始日志 */}
{resultTab === 'logs' && (<>
<div className="flex gap-3">
<LogFieldSidebar fields={fields} selectedFields={selectedFields} onFieldClick={n => setQuery(p => `${p} ${n}=""`)} onToggleDisplayField={toggleField} loading={loading} />
<div className="flex-1 min-w-0">
<EnhancedLogResults entries={pagedEntries} loading={loading} viewMode={viewMode} wrapLines={wrapLines} truncate={truncate} commonLabels={commonLabels || undefined} />
</div>
</div>
<div className="flex items-center gap-3 py-2 px-1">
<div className="flex rounded border overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
{(['raw', 'field', 'table'] as const).map(m => (
<button key={m} onClick={() => setViewMode(m)} className="px-3 py-1 text-[11px] transition-colors" style={{ background: viewMode === m ? meta.color : 'var(--dg-bg-tertiary)', color: viewMode === m ? '#fff' : 'var(--dg-text-muted)' }}>
{m === 'raw' ? '原始日志' : m === 'field' ? '字段视图' : '表格视图'}
</button>
))}
</div>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 截断</label>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 换行</label>
</div>
</>)}
{/* 统计图表 */}
{resultTab === 'stats' && <StatChartPanel statTable={statTable} engine={effEngine as LogEngine} query={query} onSaveAsAlert={() => setSaveAlertOpen(true)} onSaveAsQuery={() => setSaveQueryOpen(true)} />}
{/* 日志聚类 */}
{resultTab === 'cluster' && <LogClusterPanel clusters={clusters} />}
</div>
</div>
)}
{/* 空状态 */}
{!loading && entries.length === 0 && (
<div className="border rounded-lg flex flex-col items-center justify-center gap-2" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>输入查询语句并点击「查询」查看结果</span>
<span className="text-xs font-mono px-3 py-1 rounded" style={{ color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>{meta.queryPlaceholder}</span>
</div>
)}
{/* ===== 对话框 ===== */}
<SaveQueryDialog open={saveQueryOpen} onClose={() => setSaveQueryOpen(false)} engine={effEngine as LogEngine} query={query} timeRange={timeRange} datasourceName={currentSource?.name} />
<SaveAlertDialog open={saveAlertOpen} onClose={() => setSaveAlertOpen(false)} engine={effEngine as LogEngine} query={query} datasourceName={currentSource?.name} />
<QueryHistoryPanel visible={historyPanelOpen} onClose={() => setHistoryPanelOpen(false)} onSelect={(it: QueryHistoryItem) => { setQuery(it.query); setHistoryPanelOpen(false); }} onCreateAlert={() => { setSaveAlertOpen(true); setHistoryPanelOpen(false); }} />
<LogAlertFromQueryDialog open={alertDialogOpen} onOpenChange={setAlertDialogOpen} ctx={{ engine: effEngine as LogEngine, datasourceId: currentSource?.id, datasourceName: currentSource?.name, query, timeRange: timeRange.label }} onSaved={onRuleCreated} />
</div>
);
}
// ===== 内联紧凑版快捷查询助手 ===== // Logan.Li
function QuickQueryBar({ engine, templates, onSelect }: { engine: 'loki' | 'elasticsearch'; templates: { label: string; query: string }[]; onSelect: (q: string) => void; }) {
const [open, setOpen] = useState(false);
const labels: Record<'loki' | 'elasticsearch', string> = { loki: 'LogQL', elasticsearch: 'Lucene' };
return (
<div>
<button onClick={() => setOpen(!open)} className="flex items-center gap-1 text-[11px] px-2 py-1 rounded transition-colors" style={{ color: 'var(--dg-blue)', background: open ? 'var(--dg-bg-tertiary)' : 'transparent' }}>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--dg-blue)' }} />
{labels[engine]} 快捷查询模板
<ChevronDown className="h-3 w-3" style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 0.2s' }} />
</button>
{open && (
<div className="mt-1.5 p-2.5 rounded-lg border" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="grid grid-cols-3 lg:grid-cols-4 gap-1.5">
{templates.map((t, i) => (
<button key={i} onClick={() => { onSelect(t.query); setOpen(false); }} className="text-left px-2 py-1.5 rounded border text-[11px] transition-all hover:opacity-80" style={{ background: 'var(--dg-bg-tertiary)', borderColor: 'var(--dg-border)', color: 'var(--dg-text-secondary)' }} title={t.query}>
<span className="font-medium block truncate" style={{ color: 'var(--dg-text-primary)' }}>{t.label}</span>
<span className="block truncate opacity-60 font-mono mt-0.5 text-[10px]">{t.query}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -1,6 +1,6 @@
// log-center-types.ts — 日志中心类型(无状态:仅存连接与告警策略)// Logan.Li
export type LogCenterTab = 'loki' | 'es' | 'alerts';
export type LogCenterTab = 'loki' | 'es' | 'datasources' | 'alerts';
export type LogEngine = 'loki' | 'elasticsearch';
@@ -0,0 +1,83 @@
// 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 数据源 */
export const MOCK_DATASOURCES: LogDatasource[] = [
{ id: 1, engine: 'loki', name: 'prod-loki', url: 'http://loki.prod.local:3100', status: 'connected', description: '生产环境 Loki', isDefault: true, auth: null },
{ id: 2, engine: 'loki', name: 'dev-loki', url: 'http://loki.dev.local:3100', status: 'connected', description: '开发环境 Loki', isDefault: false, auth: null },
{ id: 3, engine: 'elasticsearch', name: 'prod-es', url: 'http://es.prod.local:9200', status: 'connected', description: '生产环境 ES', isDefault: true, auth: null },
{ id: 4, engine: 'elasticsearch', name: 'dev-es', url: 'http://es.dev.local:9200', status: 'error', description: '开发环境 ES', isDefault: false, auth: null },
];
/** 从 localStorage 加载数据源 */
export function loadDatasources(): LogDatasource[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} 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' },
};
@@ -0,0 +1,313 @@
// types-v3.ts — v3 统一日志中心类型定义 // Logan.Li
export type LogEngine = 'loki' | 'elasticsearch' | 'sls';
export type ViewMode = 'raw' | 'field' | 'table';
export type ResultTab = 'logs' | 'stats' | 'cluster';
export type ChartType = 'table' | 'line' | 'bar' | 'pie' | 'area';
export type DedupMode = 'none' | 'exact' | 'numbers' | 'signature';
export type QueryType = 'logs' | 'stats';
export interface TimeRange {
start: string; // ISO format
end: string;
label: string;
type: 'relative' | 'absolute';
}
export interface LogEntry {
timestamp: string;
level?: string;
message: string;
labels?: Record<string, string>;
raw?: string;
source?: string;
}
export interface LogField {
name: string;
type?: 'text' | 'keyword' | 'long' | 'date' | 'ip' | 'boolean';
count?: number;
}
// 统计表格 // Logan.Li
export interface StatTable {
columns: StatColumn[];
rows: Record<string, any>[];
}
export interface StatColumn {
name: string;
type: 'string' | 'number' | 'time';
}
// 统一查询请求 // Logan.Li
export interface UnifiedQueryRequest {
engine: LogEngine;
datasourceId: number;
query: string;
timeRange: TimeRange;
queryType: QueryType;
limit?: number;
}
// 统一查询响应 // Logan.Li
export interface UnifiedQueryResponse {
entries: LogEntry[];
statTable?: StatTable;
histogram: HistogramPoint[];
total: number;
queryTime: number;
bytesProcessed?: number;
coverage?: string;
status: 'precise' | 'partial' | 'failed';
}
export interface HistogramPoint {
timestamp: string;
count: number;
}
// 查询模板 // Logan.Li
export interface SavedQuery {
id: string;
name: string;
engine: LogEngine;
datasourceId: number;
query: string;
queryType: QueryType;
timeRangeLabel: string;
createdAt: number;
}
// 告警规则 // Logan.Li
export interface AlertRule {
id: number;
name: string;
expression: string;
engine: LogEngine;
severity: 'info' | 'warning' | 'critical';
enabled: boolean;
description?: string;
datasourceId?: number;
}
// 查询历史 // Logan.Li
export interface QueryHistoryItem {
id: string;
engine: LogEngine;
datasourceName: string;
query: string;
timeRange: string;
timestamp: number;
resultCount?: number;
hasAlertRule?: boolean;
}
// 日志聚类 // Logan.Li
export interface LogCluster {
pattern: string;
count: number;
sample: string;
percentage: number;
}
// 引擎元信息 // Logan.Li
export const ENGINE_META: Record<LogEngine, { name: string; color: string; queryPlaceholder: string; statPlaceholder: string; externalLink: string }> = {
loki: {
name: 'Loki',
color: '#06b6d4',
queryPlaceholder: '{app="service"} |= "keyword"',
statPlaceholder: 'sum by(level) (count_over_time({app="service"}[5m]))',
externalLink: 'Grafana',
},
elasticsearch: {
name: 'ES',
color: '#22c55e',
queryPlaceholder: 'app:service AND message:keyword',
statPlaceholder: 'SELECT level, COUNT(*) as cnt GROUP BY level',
externalLink: 'Kibana',
},
sls: {
name: 'SLS',
color: '#f97316',
queryPlaceholder: 'keyword | SELECT * WHERE level = "info"',
statPlaceholder: '* | SELECT level, COUNT(*) as cnt GROUP BY level',
externalLink: 'SLS控制台',
},
};
// 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) })),
};
}
return {
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
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 },
];
}
export function extractFields(entries: LogEntry[]): LogField[] {
const fieldMap = new Map<string, Set<string>>();
entries.forEach(e => {
Object.entries(e.labels || {}).forEach(([k, v]) => {
if (!fieldMap.has(k)) fieldMap.set(k, new Set());
fieldMap.get(k)!.add(String(v));
});
try {
const parsed = JSON.parse(e.raw || '{}');
Object.entries(parsed).forEach(([k, v]) => {
if (!fieldMap.has(k)) fieldMap.set(k, new Set());
fieldMap.get(k)!.add(String(v));
});
} catch { /* ignore */ }
});
const typeMap: Record<string, LogField['type']> = { timestamp: 'date', time: 'date', '@timestamp': 'date', level: 'keyword', status_code: 'keyword', method: 'keyword', response_time: 'long', orgId: 'long', brokerUserId: 'long' };
return Array.from(fieldMap.entries()).map(([name, values]) => ({ name, type: typeMap[name] || (values.size < 20 ? 'keyword' : 'text'), count: values.size })).sort((a, b) => a.name.localeCompare(b.name));
}
export function generateHistogramData(entries: LogEntry[]): HistogramPoint[] {
const buckets: Record<string, number> = {};
entries.forEach(e => { const hour = e.timestamp.substring(0, 13); buckets[hour] = (buckets[hour] || 0) + 1; });
return Object.entries(buckets).map(([timestamp, count]) => ({ timestamp: `${timestamp}:00`, count })).sort((a, b) => a.timestamp.localeCompare(b.timestamp));
}
// 查询历史 localStorage // Logan.Li
const HISTORY_KEY = 'aiops_log_query_history_v3';
const MAX_HISTORY = 50;
export const historyStorage = {
getAll(): QueryHistoryItem[] { try { const raw = localStorage.getItem(HISTORY_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } },
add(item: Omit<QueryHistoryItem, 'id' | 'timestamp'>): QueryHistoryItem {
const all = this.getAll();
const existing = all.findIndex(h => h.query === item.query && h.engine === item.engine);
const record: QueryHistoryItem = { ...item, id: `qh_${Date.now()}`, timestamp: Date.now() };
if (existing >= 0) all.splice(existing, 1);
all.unshift(record);
if (all.length > MAX_HISTORY) all.length = MAX_HISTORY;
localStorage.setItem(HISTORY_KEY, JSON.stringify(all));
return record;
},
remove(id: string) { localStorage.setItem(HISTORY_KEY, JSON.stringify(this.getAll().filter(h => h.id !== id))); },
clear() { localStorage.removeItem(HISTORY_KEY); },
};
// 保存查询 localStorage // Logan.Li
const SAVED_QUERY_KEY = 'aiops_saved_queries_v3';
export const savedQueryStorage = {
getAll(): SavedQuery[] { try { const raw = localStorage.getItem(SAVED_QUERY_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } },
add(q: Omit<SavedQuery, 'id' | 'createdAt'>): SavedQuery {
const all = this.getAll();
const record: SavedQuery = { ...q, id: `sq_${Date.now()}`, createdAt: Date.now() };
all.unshift(record);
localStorage.setItem(SAVED_QUERY_KEY, JSON.stringify(all));
return record;
},
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: [
{ label: 'Nginx 错误日志', query: '{job="nginx"} |= "error"' },
{ label: 'JSON 解析过滤', query: '{namespace="prod"} | json | level="warn"' },
{ label: '超时搜索', query: '{app="api"} |= "timeout"' },
{ label: '按级别统计', query: 'sum by (level) (count_over_time({job="app"}[5m]))' },
{ label: '正则匹配多级别', query: '{container="frontend"} |~ "ERROR|WARN|FATAL"' },
{ label: '慢请求 >1s', query: '{job="api"} | json | response_time > 1000' },
],
elasticsearch: [
{ label: 'API 5xx 错误', query: 'status:500 AND path:"/api/*"' },
{ label: '错误超时日志', query: 'level:error AND message:*timeout*' },
{ label: '排除调试日志', query: 'host.name:server-* AND NOT level:debug' },
{ label: '慢请求检测', query: 'response_time:>1000 AND method:POST' },
{ label: '最近1小时严重错误', query: '@timestamp:[now-1h TO now] AND level:critical' },
{ label: '特定服务错误', query: 'service:nginx AND (level:error OR level:fatal)' },
],
sls: [
{ label: '错误日志筛选', query: "* | select * where level = 'error'" },
{ label: '按级别统计', query: "* | select count(*) as cnt, level group by level" },
{ label: '时间聚合', query: "* | select date_trunc('minute', __time__) as t, count(*) group by t order by t" },
{ label: 'Top 方法统计', query: "* | select method, count(*) as cnt, avg(response_time) group by method order by cnt desc" },
{ label: '慢请求分析', query: "* | select * where response_time > 1000 order by response_time desc limit 100" },
{ label: '状态码分布', query: "* | select status_code, count(*) as cnt, count(*) * 100.0 / sum(count(*)) over() as pct group by status_code" },
],
};
@@ -0,0 +1,112 @@
// 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,
};
}