feat(log-center): 日志中心 v4 全量组件 — 统一查询面板/时间选择器/统计图表/聚类/数据源管理
This commit is contained in:
1 parent
880cf050fe
commit
bdf3c2daf4
30 files changed
+4276
-136
No files matched your search
Whitespace-only changes.
@@ -17,4 +17,6 @@ export const kubernetesApi = {
|
||||
...resourceApi,
|
||||
exportAsCSV,
|
||||
exportAsJSON,
|
||||
};
|
||||
};
|
||||
|
||||
export { podApi };
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import {
|
||||
RefreshCw, Search, Loader2, Boxes, Container,
|
||||
Network, FileText, Shield, Activity, Clock, ChevronRight,
|
||||
Download, FileCode, Pencil, Terminal, HardDrive, Plus, Upload,
|
||||
Download, FileCode, Pencil, Terminal, HardDrive, Plus, Upload, Trash2, Eye,
|
||||
} from 'lucide-react';
|
||||
import { YamlViewer } from './YamlViewer';
|
||||
import { ResourceVisualEditor } from './ResourceVisualEditor';
|
||||
@@ -23,6 +23,7 @@ import { PodExecTerminal } from './PodExecTerminal';
|
||||
import { PodFileBrowser } from './PodFileBrowser';
|
||||
import { kubernetesApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
|
||||
type ResourceType =
|
||||
| 'deployments' | 'pods' | 'services' | 'ingresses'
|
||||
@@ -53,7 +54,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
subtitle: string;
|
||||
icon: React.ComponentType<any>;
|
||||
columns: string[];
|
||||
renderRow: ( item: any, opts: { clusterCode: string; namespace: string; onDetail?: ( item: any) => void; onYaml?: (name: string) => void; onEdit?: (name: string) => void; onTerminal?: ( item: any) => void; onFileBrowser?: ( item: any) => void }) => React.ReactNode;
|
||||
renderRow: ( item: any, opts: { clusterCode: string; namespace: string; onDetail?: ( item: any) => void; onYaml?: (name: string) => void; onEdit?: (name: string) => void; onTerminal?: ( item: any) => void; onFileBrowser?: ( item: any) => void; onDeletePod?: ( item: any) => void; onPodDetail?: ( item: any) => void }) => React.ReactNode;
|
||||
}> = {
|
||||
deployments: {
|
||||
title: 'Deployments', subtitle: 'Deployment 工作负载运行状态', icon: Boxes,
|
||||
@@ -87,7 +88,12 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
const statusMap: Record<string, string> = { Running: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', Pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', Failed: 'bg-red-500/20 text-red-400 border-red-500/30' };
|
||||
return (
|
||||
<TableRow key={p.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{p.name}</TableCell>
|
||||
<TableCell className="font-medium text-slate-200 cursor-pointer hover:text-[var(--dg-blue)]" onClick={() => opts.onPodDetail?.(p)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Eye className="h-3.5 w-3.5 text-[var(--dg-text-muted)] shrink-0" />
|
||||
<span className="truncate">{p.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
|
||||
<TableCell><Badge className={statusMap[p.status] || 'bg-slate-500/20 text-slate-400'}>{p.status || 'Unknown'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{p.node_name || '-'}</TableCell>
|
||||
@@ -103,6 +109,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-blue-400" title="YAML"
|
||||
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-red-400 hover:text-red-300" title="删除"
|
||||
onClick={() => opts.onDeletePod?.(p)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -320,6 +328,7 @@ export default function NamespaceResourcePage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const type = useMemo<ResourceType>(() => {
|
||||
const segments = location.pathname.split('/');
|
||||
const last = segments[segments.length - 1];
|
||||
@@ -343,6 +352,7 @@ export default function NamespaceResourcePage() {
|
||||
|
||||
const [terminalPod, setTerminalPod] = useState<any>(null);
|
||||
const [fileBrowserPod, setFileBrowserPod] = useState<any>(null);
|
||||
const [podDetail, setPodDetail] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
kubernetesApi.getClusters().then(( res: any) => {
|
||||
@@ -434,6 +444,23 @@ export default function NamespaceResourcePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Pod 删除 // Logan.Li
|
||||
const handleDeletePod = async (p: any) => {
|
||||
const ok = await confirm({
|
||||
title: '删除 Pod',
|
||||
description: '确认删除 Pod "' + (p.name || '') + '"?操作不可恢复。',
|
||||
confirmText: '删除',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await kubernetesApi.deletePod(clusterCode || '', namespace || '', p.name);
|
||||
toast({ title: 'Pod 已删除' });
|
||||
loadData();
|
||||
} catch (e) {
|
||||
toast({ title: '删除失败', description: (e as any)?.message || String(e), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) return <div className="text-slate-400 p-8 text-center">未知资源类型: {type}</div>;
|
||||
|
||||
return (
|
||||
@@ -534,7 +561,7 @@ export default function NamespaceResourcePage() {
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 {config.title}</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit, onTerminal: ( p: any) => setTerminalPod(p), onFileBrowser: ( p: any) => setFileBrowserPod(p) }))
|
||||
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit, onTerminal: ( p: any) => setTerminalPod(p), onFileBrowser: ( p: any) => setFileBrowserPod(p), onDeletePod: handleDeletePod, onPodDetail: ( p: any) => setPodDetail(p) }))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -559,6 +586,129 @@ export default function NamespaceResourcePage() {
|
||||
podName={fileBrowserPod?.name || ''}
|
||||
containers={(fileBrowserPod?.containers || []).map(( c: any) => c.name).filter(Boolean)}
|
||||
/>
|
||||
{/* ============ Pod 详情弹窗 ============ */}
|
||||
{podDetail && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setPodDetail(null)}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg w-[680px] max-h-[85vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--dg-border)]">
|
||||
<h3 className="text-base font-semibold text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Container className="h-4 w-4 text-[var(--dg-blue)]" />
|
||||
Pod 详情
|
||||
</h3>
|
||||
<button className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]" onClick={() => setPodDetail(null)}>✕</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-3">基本信息</h4>
|
||||
{(() => {
|
||||
const p = podDetail;
|
||||
const labels = p.labels || p.metadata?.labels || {};
|
||||
const annotations = p.annotations || p.metadata?.annotations || {};
|
||||
const created = p.creationTimestamp || p.created_at || p.create_time;
|
||||
const statusMap: Record<string, string> = { Running: 'bg-emerald-500/20 text-emerald-400', Pending: 'bg-yellow-500/20 text-yellow-400', Failed: 'bg-red-500/20 text-red-400' };
|
||||
const st = p.status || 'Running';
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||
<div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">名称</span>
|
||||
<p className="text-sm text-[var(--dg-text-primary)] font-mono">{p.name || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">命名空间</span>
|
||||
<p className="text-sm text-[var(--dg-text-primary)]">{p.namespace || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">状态</span>
|
||||
<div><Badge className={statusMap[st] || 'bg-slate-500/20 text-slate-400'}>{st}</Badge></div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">创建时间</span>
|
||||
<p className="text-sm text-[var(--dg-text-primary)]">{created ? new Date(created).toLocaleString('zh-CN') : '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">节点</span>
|
||||
<p className="text-sm text-[var(--dg-text-primary)]">{p.node_name || p.node || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">Pod IP</span>
|
||||
<p className="text-sm text-[var(--dg-text-primary)] font-mono">{p.ip || p.pod_ip || '-'}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">标签</span>
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{Object.keys(labels).length === 0 ? <span className="text-xs text-[var(--dg-text-muted)]">-</span>
|
||||
: Object.entries(labels).map(([k, v]) => (
|
||||
<Badge key={k} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] text-[10px] font-mono">{k}: {String(v)}</Badge>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">注解</span>
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{Object.keys(annotations).length === 0 ? <span className="text-xs text-[var(--dg-text-muted)]">-</span>
|
||||
: Object.entries(annotations).slice(0, 6).map(([k, v]) => (
|
||||
<Badge key={k} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] text-[10px] font-mono max-w-[300px] truncate" title={String(v)}>{k}</Badge>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 现状详情(Conditions) */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-3">现状详情</h4>
|
||||
{(() => {
|
||||
const cond: any[] = podDetail.conditions || podDetail.pod_conditions || [];
|
||||
if (cond.length === 0) return <p className="text-xs text-[var(--dg-text-muted)] py-2">暂无数据</p>;
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-muted)] text-xs">类型</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] text-xs">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] text-xs">更新时间</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] text-xs">原因</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] text-xs">消息</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cond.map((c: any, idx: number) => {
|
||||
const type = c.type || c.Type || '-';
|
||||
const st = c.status || c.Status || '-';
|
||||
const ts = c.lastTransitionTime || c.LastTransitionTime || '';
|
||||
const reason = c.reason || c.Reason || '-';
|
||||
const msg = c.message || c.Message || '-';
|
||||
const isTrue = st === 'True';
|
||||
const isFalse = st === 'False';
|
||||
return (
|
||||
<TableRow key={idx} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-xs text-[var(--dg-text-primary)] font-medium">{type}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={isTrue ? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25' : isFalse ? 'bg-red-500/15 text-red-400 border-red-500/25' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}>{st}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">{ts ? new Date(ts).toLocaleString('zh-CN') : '-'}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-primary)]">{reason}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate" title={msg}>{msg}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end px-5 py-3 border-t border-[var(--dg-border)]">
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => setPodDetail(null)}>关闭</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,8 @@ import { statusColor } from '../k8s-yaml-utils';
|
||||
import { PodConditionsRow } from './helpers';
|
||||
import type { DeploymentViewProps } from './types';
|
||||
|
||||
const GRID_COLS = '28px minmax(140px,1fr) 72px 100px 96px 48px 56px 88px';
|
||||
const MIN_WIDTH = '740px';
|
||||
const GRID_COLS = '28px minmax(120px,3fr) 76px minmax(80px,1fr) minmax(70px,1fr) 56px 56px 88px';
|
||||
const MIN_WIDTH = '700px';
|
||||
const ROW_HEIGHT = 56;
|
||||
|
||||
export function PodsTab({
|
||||
|
||||
@@ -100,10 +100,13 @@ export function Breadcrumb() {
|
||||
const knownName = ROUTE_NAME_MAP[segment];
|
||||
let displayName = knownName;
|
||||
|
||||
// 日志中心子路由 alerts 与监控告警路由区分 // Logan.Li
|
||||
// 日志中心子路由区分 // Logan.Li
|
||||
if (segment === 'alerts' && paths[index - 1] === 'log-center') {
|
||||
displayName = '日志告警';
|
||||
}
|
||||
if (segment === 'datasources' && paths[index - 1] === 'log-center') {
|
||||
displayName = '日志库';
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
// Dynamic parameter - could be cluster code, namespace, resource name, etc.
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
FileSearch,
|
||||
HardDrive,
|
||||
LogIn,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
@@ -125,8 +126,9 @@ const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: 'log-center', name: '日志中心', icon: ScrollText, permission: 'monitor:alert',
|
||||
children: [
|
||||
{ id: 'log-loki', name: 'Loki', icon: FileSearch, path: '/log-center/loki', permission: 'monitor:alert' },
|
||||
{ id: 'log-es', name: 'ES', icon: Database, path: '/log-center/es', permission: 'monitor:alert' },
|
||||
{ id: 'log-loki', name: 'Loki查询', icon: FileSearch, path: '/log-center/loki', permission: 'monitor:alert' },
|
||||
{ id: 'log-es', name: 'ES查询', icon: Database, path: '/log-center/es', permission: 'monitor:alert' },
|
||||
{ id: 'log-datasources', name: '日志库', icon: HardDrive, path: '/log-center/datasources', permission: 'monitor:alert' },
|
||||
{ id: 'log-alerts', name: '日志告警', icon: AlertTriangle, path: '/log-center/alerts', permission: 'monitor:alert' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user