diff --git a/backend/static/.gitkeep b/backend/static/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/api/kubernetes/index.ts b/frontend/src/api/kubernetes/index.ts index ac8924f..e2ef0c6 100644 --- a/frontend/src/api/kubernetes/index.ts +++ b/frontend/src/api/kubernetes/index.ts @@ -17,4 +17,6 @@ export const kubernetesApi = { ...resourceApi, exportAsCSV, exportAsJSON, -}; \ No newline at end of file +}; + +export { podApi }; \ No newline at end of file diff --git a/frontend/src/components/kubernetes/NamespaceResourcePage.tsx b/frontend/src/components/kubernetes/NamespaceResourcePage.tsx index fb0f04d..5586822 100644 --- a/frontend/src/components/kubernetes/NamespaceResourcePage.tsx +++ b/frontend/src/components/kubernetes/NamespaceResourcePage.tsx @@ -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; 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 = { 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 ( - {p.name} + opts.onPodDetail?.(p)}> +
+ + {p.name} +
+
{p.namespace} {p.status || 'Unknown'} {p.node_name || '-'} @@ -103,6 +109,8 @@ const RESOURCE_CONFIG: Record (opts as Record).onFileBrowser?.(p)}> +
@@ -320,6 +328,7 @@ export default function NamespaceResourcePage() { const location = useLocation(); const navigate = useNavigate(); const { toast } = useToast(); + const confirm = useConfirm(); const type = useMemo(() => { const segments = location.pathname.split('/'); const last = segments[segments.length - 1]; @@ -343,6 +352,7 @@ export default function NamespaceResourcePage() { const [terminalPod, setTerminalPod] = useState(null); const [fileBrowserPod, setFileBrowserPod] = useState(null); + const [podDetail, setPodDetail] = useState(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
未知资源类型: {type}
; return ( @@ -534,7 +561,7 @@ export default function NamespaceResourcePage() { ) : filtered.length === 0 ? ( 暂无 {config.title} ) : ( - 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) })) )} @@ -559,6 +586,129 @@ export default function NamespaceResourcePage() { podName={fileBrowserPod?.name || ''} containers={(fileBrowserPod?.containers || []).map(( c: any) => c.name).filter(Boolean)} /> + {/* ============ Pod 详情弹窗 ============ */} + {podDetail && ( +
setPodDetail(null)}> +
e.stopPropagation()}> +
+

+ + Pod 详情 +

+ +
+
+ {/* 基本信息 */} +
+

基本信息

+ {(() => { + 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 = { 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 ( +
+
+ 名称 +

{p.name || '-'}

+
+
+ 命名空间 +

{p.namespace || '-'}

+
+
+ 状态 +
{st}
+
+
+ 创建时间 +

{created ? new Date(created).toLocaleString('zh-CN') : '-'}

+
+
+ 节点 +

{p.node_name || p.node || '-'}

+
+
+ Pod IP +

{p.ip || p.pod_ip || '-'}

+
+
+ 标签 +
+ {Object.keys(labels).length === 0 ? - + : Object.entries(labels).map(([k, v]) => ( + {k}: {String(v)} + )) + } +
+
+
+ 注解 +
+ {Object.keys(annotations).length === 0 ? - + : Object.entries(annotations).slice(0, 6).map(([k, v]) => ( + {k} + )) + } +
+
+
+ ); + })()} +
+ + {/* 现状详情(Conditions) */} +
+

现状详情

+ {(() => { + const cond: any[] = podDetail.conditions || podDetail.pod_conditions || []; + if (cond.length === 0) return

暂无数据

; + return ( + + + + 类型 + 状态 + 更新时间 + 原因 + 消息 + + + + {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 ( + + {type} + + {st} + + {ts ? new Date(ts).toLocaleString('zh-CN') : '-'} + {reason} + {msg} + + ); + })} + +
+ ); + })()} +
+
+
+ +
+
+
+ )} ); } diff --git a/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx b/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx index 1b50c0e..81adeef 100644 --- a/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx +++ b/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx @@ -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({ diff --git a/frontend/src/components/layout/Breadcrumb.tsx b/frontend/src/components/layout/Breadcrumb.tsx index 45da3fd..aae28bd 100644 --- a/frontend/src/components/layout/Breadcrumb.tsx +++ b/frontend/src/components/layout/Breadcrumb.tsx @@ -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. diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index fa2d597..c0df1c1 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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' }, ], }, diff --git a/frontend/src/components/monitor/log-center/DatasourceCard.tsx b/frontend/src/components/monitor/log-center/DatasourceCard.tsx new file mode 100644 index 0000000..834316a --- /dev/null +++ b/frontend/src/components/monitor/log-center/DatasourceCard.tsx @@ -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 ( +
{ + (e.currentTarget as HTMLDivElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'; + }} + > + {/* 引擎色条 */} +
+ + {/* 头部:图标 + 名称 + 默认标记 */} +
+
+ +
+
+
+ + {ds.name} + + {ds.isDefault && ( + + 默认 + + )} +
+ {cfg.label} +
+
+ + {/* URL */} +
+ + + {ds.url} + +
+ + {/* 描述 */} + {ds.description && ( +
+ {ds.description} +
+ )} + + {/* 状态行 + 操作按钮 */} +
+ + +
+ {/* 测试连接 */} + + + {/* 编辑 */} + + + {/* 删除 */} + +
+
+ + {/* 测试结果提示 */} + {showResult && ( +
+ {testResult.success ? : } + {testResult.message} +
+ )} +
+ ); +} diff --git a/frontend/src/components/monitor/log-center/DatasourceDialog.tsx b/frontend/src/components/monitor/log-center/DatasourceDialog.tsx new file mode 100644 index 0000000..30ec62f --- /dev/null +++ b/frontend/src/components/monitor/log-center/DatasourceDialog.tsx @@ -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 ( +
+
e.stopPropagation()} + > + {/* 标题栏 */} +
+

+ {editing ? '编辑数据源' : '添加数据源'} +

+ +
+ + {/* 表单内容 */} +
+ {/* 引擎类型选择 */} +
+ +
+ + {engineMenuOpen && ( +
+ {Object.entries(ENGINE_CONFIG).map(([key, c]) => { + const Icon = c.icon; + return ( + + ); + })} +
+ )} +
+
+ + {/* 数据源名称 */} +
+ + setName(e.target.value)} + placeholder="如:prod-loki" + style={{ width: '100%', padding: '8px 12px', borderRadius: 6, fontSize: 13 }} + /> +
+ + {/* URL */} +
+ + setUrl(e.target.value)} + placeholder={cfg.placeholder} + style={{ width: '100%', padding: '8px 12px', borderRadius: 6, fontSize: 13 }} + /> +
+ + {/* 描述 */} +
+ + setDescription(e.target.value)} + placeholder="可选,如:生产环境 Loki" + style={{ width: '100%', padding: '8px 12px', borderRadius: 6, fontSize: 13 }} + /> +
+ + {/* Basic Auth 开关 */} + setUseAuth(!useAuth)} + /> + + {/* 认证信息 */} + {useAuth && ( +
+
+ + setUsername(e.target.value)} + placeholder="username" + style={{ width: '100%', padding: '8px 12px', borderRadius: 6, fontSize: 13 }} + /> +
+
+ + setPassword(e.target.value)} + placeholder="password" + style={{ width: '100%', padding: '8px 12px', borderRadius: 6, fontSize: 13 }} + /> +
+
+ )} + + {/* 设为默认 */} + setIsDefault(!isDefault)} + icon={} + /> +
+ + {/* 底部按钮 */} +
+ + +
+
+
+ ); +} + +// ==================== 内联子组件:开关行 ==================== // Logan.Li + +function ToggleRow({ + label, + enabled, + onToggle, + icon, +}: { + label: string; + enabled: boolean; + onToggle: () => void; + icon?: React.ReactNode; +}) { + return ( +
+ + {icon} + {label} + + +
+ ); +} diff --git a/frontend/src/components/monitor/log-center/DeleteConfirmDialog.tsx b/frontend/src/components/monitor/log-center/DeleteConfirmDialog.tsx new file mode 100644 index 0000000..8ed5378 --- /dev/null +++ b/frontend/src/components/monitor/log-center/DeleteConfirmDialog.tsx @@ -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 ( +
+
e.stopPropagation()} + > + {/* 标题 */} +
+ +

+ 确认删除数据源 +

+
+ + {/* 内容 */} +

+ 确定要删除数据源 {datasource.name} 吗? + 此操作不可恢复。 +

+ + {hasAlerts && ( +
+ + 该数据源有关联的告警规则,请先删除告警后再删除数据源。 +
+ )} + + {/* 按钮 */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/monitor/log-center/EnhancedLogResults.tsx b/frontend/src/components/monitor/log-center/EnhancedLogResults.tsx new file mode 100644 index 0000000..70d6449 --- /dev/null +++ b/frontend/src/components/monitor/log-center/EnhancedLogResults.tsx @@ -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; + // 标签操作回调 + 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 = { + 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(); + 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 | null { + if (entries.length === 0) return null; + const first = entries[0].labels || {}; + const common: Record = {}; + 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 ( +
+ {/* 日志行 */} +
setExpanded(!expanded)} + > + {/* 展开图标 */} + + {expanded ? : } + + + {/* 时间戳 */} + {showTime && ( + + {entry.timestamp} + + )} + + {/* 日志级别 */} + {entry.level && ( + + {entry.level.toUpperCase()} + + )} + + {/* 日志内容 */} + + {trunc && entry.message.length > 300 && !expanded ? entry.message.slice(0, 300) + '...' : entry.message} + + + {/* 复制按钮(hover 显示)*/} + +
+ + {/* 展开详情:Log labels + Detected fields */} + {expanded && ( +
+ {/* Log labels */} + {Object.keys(labels).length > 0 && ( +
+

Log labels

+
+ {Object.entries(labels).map(([k, v]) => ( +
+ + + {k} + {v} +
+ ))} +
+
+ )} + + {/* Detected fields */} + {detected.length > 0 && ( +
+

Detected fields

+
+ {detected.map(({ name, value }) => ( +
+ + + + {name} + {value} +
+ ))} +
+
+ )} +
+ )} +
+ ); +} + +// ===== 字段视图 ===== // +function FieldView({ entries, wrap, trunc, search }: { entries: LogEntry[]; wrap: boolean; trunc: boolean; search?: string }) { + const columns = useMemo(() => { + const colSet = new Set(); + 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 ( +
+ + + + {columns.map(col => ( + + ))} + + + + {entries.map((e, i) => ( + + {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 ( + + ); + })} + + ))} + +
{col}
+ {trunc && val.length > 100 ? val.slice(0, 100) + '...' : val} +
+
+ ); +} + +// ===== 表格视图 ===== // +function TableView({ entries, wrap, trunc }: { entries: LogEntry[]; wrap: boolean; trunc: boolean }) { + return ( +
+ + + + + + + + + + {entries.map((e, i) => ( + + + + + + ))} + +
TimeLevelMessage
{e.timestamp} + {e.level ? {e.level} : '—'} + + {trunc && e.message.length > 200 ? e.message.slice(0, 200) + '...' : e.message} +
+
+ ); +} + +// ===== 主组件 ===== // +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 ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+
+
+ ))} +
+ ); + } + + if (entries.length === 0) { + return ( +
+ 暂无日志 +
+ ); + } + + return ( +
+ {/* Common labels */} + {commonLabels && Object.keys(commonLabels).length > 0 && ( +
+ Common labels: + {Object.entries(commonLabels).map(([k, v]) => ( + + {k}={v} + + ))} +
+ )} + + {/* 工具栏 */} +
+ {/* Time 开关 */} + + + {/* Unique labels */} + + + {/* Wrap lines */} + + + {/* Prettify JSON */} + + + {/* Dedup */} +
+ Dedup + {(['none', 'exact', 'numbers', 'signature'] as DedupMode[]).map(m => ( + + ))} +
+ + {/* Sort order */} +
+ Display results + {(['newest', 'oldest'] as const).map(o => ( + + ))} +
+
+ + {/* 结果内容 */} +
+ {viewMode === 'raw' && sortedEntries.map((e, i) => ( + + ))} + {viewMode === 'field' && } + {viewMode === 'table' && } +
+
+ ); +} diff --git a/frontend/src/components/monitor/log-center/LogAlertBell.tsx b/frontend/src/components/monitor/log-center/LogAlertBell.tsx new file mode 100644 index 0000000..a2adc4c --- /dev/null +++ b/frontend/src/components/monitor/log-center/LogAlertBell.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/monitor/log-center/LogAlertFromQueryDialog.tsx b/frontend/src/components/monitor/log-center/LogAlertFromQueryDialog.tsx index 6627cb3..fabc99e 100644 --- a/frontend/src/components/monitor/log-center/LogAlertFromQueryDialog.tsx +++ b/frontend/src/components/monitor/log-center/LogAlertFromQueryDialog.tsx @@ -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 = { + 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 ( - - - - - - 叮当 · 添加告警 - - - 将当前查询条件保存为日志告警规则,命中时触发通知 - - - {!ctx?.query?.trim() ? ( -

请先在 Loki 或 ES 查询页输入查询语句

- ) : ( -
+ <> +
onOpenChange(false)} /> +
+ {/* 头部 */} +
+
+ + 创建告警规则 +
+ +
+ + {/* 内容 */} +
+ {/* 查询来源信息 */} +
+ + {meta.name} + + {ctx.datasourceName || '默认数据源'} + {ctx.timeRange || '1h'} +
+ +
+ + 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)' }} + /> +
+ +
+ +