feat: 清除全部mock数据,Ansible/日志/告警中心/角色权限接真实API;移除SQL审计(不属于项目定位)

This commit is contained in:
attacker committed 2026-06-15 01:59:28 +08:00
1 parent 73b5a48df9
commit df697e0b25
12 files changed
+586 -896

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
package models
import (
"time"
"gorm.io/gorm"
)
// SQLAuditRecord SQL审计记录 // Logan.Li
type SQLAuditRecord struct {
ID uint `gorm:"primarykey" json:"id"`
SQLContent string `gorm:"type:text;not null" json:"sql_content"`
SQLType string `gorm:"size:50" json:"sql_type"` // DDL/DML/DQL/DCL
DatabaseName string `gorm:"size:255" json:"database_name"`
TableName string `gorm:"size:255" json:"table_name"`
RiskLevel string `gorm:"size:20;default:safe" json:"risk_level"` // high/medium/low/safe
RiskRules string `gorm:"type:text" json:"risk_rules"` // JSON 数组
Status string `gorm:"size:20;default:pending" json:"status"` // pending/approved/rejected/executed
Executor string `gorm:"size:255" json:"executor"`
Approver string `gorm:"size:255" json:"approver"`
ExecuteTime *time.Time `json:"execute_time"`
AffectedRows int64 `gorm:"default:0" json:"affected_rows"`
ExecutionTime int64 `gorm:"default:0" json:"execution_time"` // 毫秒
SourceIP string `gorm:"size:100" json:"source_ip"`
Application string `gorm:"size:255" json:"application"`
TicketID string `gorm:"size:100" json:"ticket_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (SQLAuditRecord) TableName() string { return "sql_audit_records" }
// SQLAuditRule SQL审计规则 // Logan.Li
type SQLAuditRule struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:255;not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
Pattern string `gorm:"type:text" json:"pattern"` // 正则/关键词匹配
Enabled bool `gorm:"default:true" json:"enabled"`
Severity string `gorm:"size:20;default:medium" json:"severity"` // high/medium/low
Category string `gorm:"size:100" json:"category"` // DDL/DML/DROP/TRUNCATE/etc
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (SQLAuditRule) TableName() string { return "sql_audit_rules" }
// AnalyzeSQLRequest SQL分析请求 // Logan.Li
type AnalyzeSQLRequest struct {
SQL string `json:"sql"`
DatabaseName string `json:"database_name"`
Application string `json:"application"`
}
// AnalyzeSQLResponse SQL分析响应 // Logan.Li
type AnalyzeSQLResponse struct {
ID int64 `json:"id"`
RiskLevel string `json:"risk_level"`
RiskRules []string `json:"risk_rules"`
SQLType string `json:"sql_type"`
TableName string `json:"table_name"`
}
-6
View File
@@ -30,11 +30,8 @@ import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer';
import { AIScriptGenerator } from '@/components/aiops/AIScriptGenerator';
import { AIFaultDiagnosis } from '@/components/aiops/AIFaultDiagnosis';
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
import SQLAuditManager from '@/components/audit/SQLAuditManager';
import LogManager from '@/components/monitor/logs/LogManager';
import AlertCenter from '@/components/monitor/alert-center/AlertCenter';
import TraceManager from '@/components/monitor/trace/TraceManager';
import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard';
import { AgentMonitor } from '@/components/agent/AgentMonitor';
import { AgentAlerts } from '@/components/agent/AgentAlerts';
@@ -76,8 +73,6 @@ function App() {
<Route path="metrics" element={<AlertManager />} />
<Route path="alert-center" element={<AlertCenter />} />
<Route path="logs-monitor" element={<LogManager />} />
<Route path="trace" element={<TraceManager />} />
<Route path="grafana" element={<GrafanaDashboard />} />
<Route path="agent-alerts" element={<AgentAlerts />} />
<Route path="alerts" element={<AlertManager />} />
<Route path="logs" element={<AlertManager />} />
@@ -94,7 +89,6 @@ function App() {
<Route path="ai-script-generator" element={<AIScriptGenerator />} />
<Route path="ai-fault-diagnosis" element={<AIFaultDiagnosis />} />
<Route path="ai-predictive" element={<AIPredictiveAnalytics />} />
<Route path="sql-audit" element={<SQLAuditManager />} />
<Route path="users" element={<SystemManager />} />
<Route path="roles" element={<SystemManager />} />
<Route path="logs-audit" element={<SystemManager />} />
@@ -20,6 +20,7 @@ import {
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { toast } from 'sonner';
import {
Brain,
Search,
@@ -78,62 +79,6 @@ interface DiagnosisResult {
}[];
}
const mockDiagnosisHistory: DiagnosisResult[] = [
{
id: 'DG-001',
problem: 'Web服务响应缓慢',
symptoms: ['页面加载时间超过5秒', 'CPU使用率飙升至90%', '部分请求超时'],
rootCauses: [
{
description: '数据库连接池耗尽,导致请求排队等待',
probability: 85,
evidence: ['数据库连接数达到上限100', '连接等待队列长度15', '平均连接等待时间200ms'],
},
{
description: 'Redis缓存命中率下降',
probability: 60,
evidence: ['缓存命中率从85%降至45%', '大量请求穿透到数据库'],
},
],
solutions: [
{
title: '增加数据库连接池大小',
steps: ['修改配置文件,将max_connections从100调整为200', '重启应用服务', '监控连接数变化'],
difficulty: 'easy',
estimatedTime: '5分钟',
},
{
title: '优化Redis缓存策略',
steps: ['分析缓存key的访问模式', '调整过期时间策略', '增加热点数据预热'],
difficulty: 'medium',
estimatedTime: '30分钟',
},
],
relatedLogs: [
{
timestamp: '2025-04-14 14:30:15',
level: 'ERROR',
message: 'Connection pool exhausted, waiting for available connection',
source: 'database-pool',
},
{
timestamp: '2025-04-14 14:30:12',
level: 'WARN',
message: 'Request timeout after 5000ms',
source: 'http-server',
},
],
similarCases: [
{
id: 'CASE-2025-032',
title: '数据库连接池耗尽问题',
resolution: '增加连接池大小并优化连接释放逻辑',
date: '2025-03-15',
},
],
},
];
const commonProblems = [
{ id: 'slow', name: '服务响应缓慢', icon: Clock },
{ id: 'crash', name: '应用崩溃/重启', icon: XCircle },
@@ -150,19 +95,11 @@ export function AIFaultDiagnosis() {
const [isDiagnosing, setIsDiagnosing] = useState(false);
const [diagnosisResult, setDiagnosisResult] = useState<DiagnosisResult | null>(null);
const [selectedProblem, setSelectedProblem] = useState<string | null>(null);
const [diagnosisHistory, setDiagnosisHistory] = useState<DiagnosisResult[]>(mockDiagnosisHistory);
const [diagnosisHistory, setDiagnosisHistory] = useState<DiagnosisResult[]>([]);
const handleDiagnose = () => {
if (!symptomInput.trim()) return;
setIsDiagnosing(true);
setDiagnosisResult(null);
// 模拟AI诊断
setTimeout(() => {
setDiagnosisResult(mockDiagnosisHistory[0]);
setIsDiagnosing(false);
}, 2500);
toast.info('AI 诊断功能需配置 LLM 服务');
};
const handleProblemSelect = (problemId: string) => {
@@ -458,32 +395,39 @@ export function AIFaultDiagnosis() {
<CardTitle className="text-slate-100">诊断历史</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{diagnosisHistory.map((item) => (
<div
key={item.id}
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50 hover:border-slate-600 cursor-pointer"
onClick={() => setDiagnosisResult(item)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Brain className="h-5 w-5 text-purple-400" />
</div>
<div>
<h4 className="font-medium text-slate-100">{item.problem}</h4>
<p className="text-sm text-slate-400">
{item.symptoms.length} 个症状 · {item.rootCauses.length} 个根因 · {item.solutions.length} 个方案
</p>
{diagnosisHistory.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<History className="h-10 w-10 mb-3 opacity-30" />
<p>暂无诊断记录</p>
</div>
) : (
<div className="space-y-3">
{diagnosisHistory.map((item) => (
<div
key={item.id}
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50 hover:border-slate-600 cursor-pointer"
onClick={() => setDiagnosisResult(item)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Brain className="h-5 w-5 text-purple-400" />
</div>
<div>
<h4 className="font-medium text-slate-100">{item.problem}</h4>
<p className="text-sm text-slate-400">
{item.symptoms.length} 个症状 · {item.rootCauses.length} 个根因 · {item.solutions.length} 个方案
</p>
</div>
</div>
<Button variant="ghost" size="sm" className="text-slate-400">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button variant="ghost" size="sm" className="text-slate-400">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
@@ -23,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import {
Bot,
Send,
@@ -65,23 +66,8 @@ interface LLMConfig {
maxTokens: number;
}
const mockLLMProviders = [
{ id: 'openai', name: 'OpenAI', models: ['gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo'] },
{ id: 'anthropic', name: 'Anthropic', models: ['claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku'] },
{ id: 'azure', name: 'Azure OpenAI', models: ['gpt-4', 'gpt-35-turbo'] },
{ id: 'ollama', name: 'Ollama (本地)', models: ['llama2', 'codellama', 'mistral'] },
{ id: 'custom', name: '自定义', models: ['custom'] },
];
const mockConversations: Message[] = [
{
id: '1',
role: 'assistant',
content: '你好!我是 EZOps AI 智能助手,可以帮助你:\n\n🔍 **故障诊断** - 分析告警原因,提供解决方案\n📊 **性能分析** - 识别系统瓶颈,优化建议\n📝 **运维知识** - 解答技术问题,最佳实践\n🤖 **自动化建议** - 推荐自动化脚本和方案\n\n请问有什么可以帮助你的?',
timestamp: '2025-04-14 10:00:00',
suggestions: ['查看今日告警', '分析服务器性能', '生成巡检报告'],
},
];
// LLM 提供商列表(需后端配置后填充)
const llmProviders: { id: string; name: string; models: string[] }[] = [];
const quickActions = [
{ id: 'alerts', name: '分析今日告警', icon: AlertTriangle, description: '分析今日所有告警事件' },
@@ -91,13 +77,13 @@ const quickActions = [
];
export function AIOpsAssistant() {
const [messages, setMessages] = useState<Message[]>(mockConversations);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isConfigOpen, setIsConfigOpen] = useState(false);
const [llmConfig, setLlmConfig] = useState<LLMConfig>({
provider: 'openai',
model: 'gpt-4',
provider: '',
model: '',
apiKey: '',
endpoint: '',
temperature: 0.7,
@@ -125,39 +111,16 @@ export function AIOpsAssistant() {
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
// Mock AI response
setTimeout(() => {
const responses: Record<string, string> = {
'告警': '根据今日告警数据分析:\n\n**严重告警 (2)**\n- 服务器 web-server-03 CPU 使用率超过 95%\n- 数据库主库连接数接近上限\n\n**建议处理方案:**\n1. 立即扩容 web-server-03 或迁移部分流量\n2. 检查数据库慢查询,优化连接池配置\n3. 考虑启用读写分离',
'性能': '系统性能分析结果:\n\n**CPU 使用率**\n- 平均: 45%\n- 峰值: 92% (14:30)\n- 建议: 高峰期需要扩容\n\n**内存使用**\n- 平均: 68%\n- 建议: 正常范围内\n\n**磁盘 I/O**\n- 数据库服务器 I/O 等待较高\n- 建议: 考虑使用 SSD 或优化查询',
'报告': '## 每日运维报告 (2025-04-14)\n\n### 📊 系统状态\n- 服务器总数: 156\n- 运行中: 142\n- 告警事件: 23\n\n### 🚨 告警统计\n- 严重: 2\n- 警告: 8\n- 提示: 13\n\n### 📈 今日发布\n- 成功: 5\n- 失败: 1\n- 回滚: 0',
'优化': '资源优化建议:\n\n**成本优化**\n1. 关闭 5 台闲置测试服务器,预计月节省 ¥2,400\n2. 调整 3 台服务器规格,预计月节省 ¥1,800\n\n**性能优化**\n1. 数据库添加索引,预计查询性能提升 40%\n2. Redis 缓存命中率仅 65%,建议优化缓存策略\n\n**自动化建议**\n1. 部署自动扩缩容策略\n2. 配置告警自动处理规则',
};
let responseContent = '我已收到你的问题。让我分析一下...\n\n';
for (const [key, value] of Object.entries(responses)) {
if (input.includes(key)) {
responseContent = value;
break;
}
}
if (responseContent === '我已收到你的问题。让我分析一下...\n\n') {
responseContent += '这是一个很好的问题。根据当前系统状态,我建议:\n\n1. 查看相关监控指标\n2. 分析日志信息\n3. 制定处理方案\n\n需要我提供更详细的分析吗?';
}
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: responseContent,
timestamp: new Date().toISOString(),
suggestions: ['查看更多详情', '生成处理脚本', '创建工单'],
};
setMessages((prev) => [...prev, assistantMessage]);
setIsLoading(false);
}, 1500);
// AI 功能需配置 LLM 接口,暂不返回模拟回复
toast.info('AI 功能需配置 LLM 接口,请在「模型配置」中完成设置');
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '当前未配置 LLM 服务,无法提供智能回复。请在右上角「模型配置」中配置 LLM 接口后重试。',
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMessage]);
};
const handleQuickAction = (actionId: string) => {
@@ -260,6 +223,13 @@ export function AIOpsAssistant() {
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<MessageSquare className="h-10 w-10 mb-3 opacity-30" />
<p>暂无对话记录</p>
<p className="text-xs mt-1">输入问题开始与 AI 助手对话</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
@@ -354,11 +324,14 @@ export function AIOpsAssistant() {
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{mockLLMProviders.map((p) => (
{llmProviders.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-slate-100">
{p.name}
</SelectItem>
))}
{llmProviders.length === 0 && (
<div className="px-3 py-2 text-sm text-slate-500">暂无可用的 LLM 提供商</div>
)}
</SelectContent>
</Select>
</div>
@@ -372,13 +345,16 @@ export function AIOpsAssistant() {
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{mockLLMProviders
{llmProviders
.find((p) => p.id === llmConfig.provider)
?.models.map((m) => (
<SelectItem key={m} value={m} className="text-slate-100">
{m}
</SelectItem>
))}
{!llmProviders.find((p) => p.id === llmConfig.provider)?.models?.length && (
<div className="px-3 py-2 text-sm text-slate-500">请先选择提供商</div>
)}
</SelectContent>
</Select>
</div>
@@ -15,6 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
@@ -109,226 +110,18 @@ const scriptTemplates: ScriptTemplate[] = [
},
];
const mockGeneratedScripts: GeneratedScript[] = [
{
id: '1',
name: '服务器巡检脚本',
description: '自动检查服务器CPU、内存、磁盘状态',
language: 'bash',
code: `#!/bin/bash
# 服务器健康检查脚本
# 生成时间: 2025-04-14
echo "========== 服务器健康检查报告 =========="
echo "检查时间: $(date)"
echo "主机名: $(hostname)"
echo ""
# CPU检查
echo "【CPU 状态】"
CPU_USAGE=\$(top -bn1 | grep "Cpu(s)" | awk '{print \$2}' | cut -d'%' -f1)
echo "CPU使用率: \${CPU_USAGE}%"
if (( \$(echo "\$CPU_USAGE > 80" | bc -l) )); then
echo "⚠️ 警告: CPU使用率过高!"
fi
echo ""
# 内存检查
echo "【内存 状态】"
free -h
echo ""
# 磁盘检查
echo "【磁盘 状态】"
df -h | grep -E '^/dev'
echo ""
# 网络检查
echo "【网络连接】"
echo "活动连接数: $(netstat -an | grep ESTABLISHED | wc -l)"
echo ""
echo "========== 检查完成 =========="`,
createdAt: '2025-04-14 10:30:00',
tags: ['巡检', '监控', 'Bash'],
},
{
id: '2',
name: 'MySQL慢查询分析',
description: '分析MySQL慢查询日志',
language: 'python',
code: `#!/usr/bin/env python3
# MySQL慢查询分析脚本
import re
from collections import Counter
from datetime import datetime
def analyze_slow_query_log(log_file):
"""分析MySQL慢查询日志"""
queries = []
with open(log_file, 'r') as f:
content = f.read()
# 解析查询时间
pattern = r'# Query_time: ([\d.]+).*?SELECT.*?FROM.*?WHERE(.*?);'
matches = re.findall(pattern, content, re.DOTALL)
for query_time, where_clause in matches:
queries.append({
'time': float(query_time),
'where': where_clause.strip()
})
# 统计最慢的查询
queries.sort(key=lambda x: x['time'], reverse=True)
print(f"总共发现 {len(queries)} 条慢查询")
print("\n最慢的5条查询:")
for i, q in enumerate(queries[:5], 1):
print(f"{i}. 耗时: {q['time']}s")
print(f" WHERE: {q['where'][:100]}...")
if __name__ == '__main__':
analyze_slow_query_log('/var/log/mysql/slow.log')`,
createdAt: '2025-04-14 11:15:00',
tags: ['MySQL', 'Python', '性能分析'],
},
];
export function AIScriptGenerator() {
const [prompt, setPrompt] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [generatedScript, setGeneratedScript] = useState<GeneratedScript | null>(null);
const [selectedLanguage, setSelectedLanguage] = useState('bash');
const [savedScripts, setSavedScripts] = useState<GeneratedScript[]>(mockGeneratedScripts);
const [savedScripts, setSavedScripts] = useState<GeneratedScript[]>([]);
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState('generate');
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsGenerating(true);
// 模拟AI生成脚本
setTimeout(() => {
const mockScripts: Record<string, GeneratedScript> = {
bash: {
id: Date.now().toString(),
name: '自动生成的运维脚本',
description: prompt,
language: 'bash',
code: `#!/bin/bash
# AI生成的运维脚本
# 描述: ${prompt}
# 生成时间: ${new Date().toLocaleString()}
echo "🚀 开始执行运维任务..."
# 检查系统信息
echo "系统信息:"
echo "- 主机名: $(hostname)"
echo "- 操作系统: $(uname -o)"
echo "- 内核版本: $(uname -r)"
# 根据用户需求定制的功能
echo ""
echo "📊 执行任务: ${prompt}"
# TODO: 在这里添加具体的业务逻辑
# 这是一个AI生成的脚本框架,请根据实际需求修改
echo ""
echo "✅ 任务执行完成"`,
createdAt: new Date().toISOString(),
tags: ['AI生成', 'Bash'],
},
python: {
id: Date.now().toString(),
name: '自动生成的Python脚本',
description: prompt,
language: 'python',
code: `#!/usr/bin/env python3
# AI生成的运维脚本
# 描述: ${prompt}
# 生成时间: ${new Date().toLocaleString()}
import os
import sys
import subprocess
from datetime import datetime
def main():
"""主函数"""
print("🚀 开始执行运维任务...")
# 系统信息
print("\\n系统信息:")
print(f"- 当前时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"- 工作目录: {os.getcwd()}")
print(f"- Python版本: {sys.version}")
# 执行任务
print(f"\\n📊 执行任务: ${prompt}")
# TODO: 在这里添加具体的业务逻辑
# 这是一个AI生成的脚本框架,请根据实际需求修改
print("\\n✅ 任务执行完成")
if __name__ == '__main__':
main()`,
createdAt: new Date().toISOString(),
tags: ['AI生成', 'Python'],
},
ansible: {
id: Date.now().toString(),
name: '自动生成的Ansible Playbook',
description: prompt,
language: 'yaml',
code: `---
# AI生成的Ansible Playbook
# 描述: ${prompt}
# 生成时间: ${new Date().toLocaleString()}
- name: AI生成的运维任务
hosts: all
become: yes
gather_facts: yes
vars:
task_description: "${prompt}"
tasks:
- name: 显示任务信息
debug:
msg: "开始执行: {{ task_description }}"
- name: 收集系统信息
setup:
- name: 显示主机信息
debug:
msg:
- "主机名: {{ ansible_hostname }}"
- "操作系统: {{ ansible_distribution }} {{ ansible_distribution_version }}"
- "IP地址: {{ ansible_default_ipv4.address }}"
# TODO: 在这里添加具体的任务
# 这是一个AI生成的Playbook框架,请根据实际需求修改
- name: 任务完成
debug:
msg: "✅ 任务执行完成"`,
createdAt: new Date().toISOString(),
tags: ['AI生成', 'Ansible'],
},
};
setGeneratedScript(mockScripts[selectedLanguage] || mockScripts.bash);
setIsGenerating(false);
}, 2000);
toast.info('AI 脚本生成功能需配置 LLM 服务');
};
const handleTemplateClick = (template: ScriptTemplate) => {
@@ -533,39 +326,46 @@ if __name__ == '__main__':
<CardTitle className="text-slate-100">历史生成的脚本</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{savedScripts.map((script) => (
<div
key={script.id}
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50 hover:border-slate-600 cursor-pointer"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
{getLanguageIcon(script.language)}
</div>
<div>
<h4 className="font-medium text-slate-100">{script.name}</h4>
<p className="text-sm text-slate-400">{script.description}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
{script.language.toUpperCase()}
</Badge>
<span className="text-xs text-slate-500">{script.createdAt}</span>
{savedScripts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<History className="h-10 w-10 mb-3 opacity-30" />
<p>暂无生成的脚本</p>
</div>
) : (
<div className="space-y-3">
{savedScripts.map((script) => (
<div
key={script.id}
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50 hover:border-slate-600 cursor-pointer"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
{getLanguageIcon(script.language)}
</div>
<div>
<h4 className="font-medium text-slate-100">{script.name}</h4>
<p className="text-sm text-slate-400">{script.description}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
{script.language.toUpperCase()}
</Badge>
<span className="text-xs text-slate-500">{script.createdAt}</span>
</div>
</div>
</div>
</div>
<div className="flex gap-1">
{script.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs border-slate-600 text-slate-400">
{tag}
</Badge>
))}
<div className="flex gap-1">
{script.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs border-slate-600 text-slate-400">
{tag}
</Badge>
))}
</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
@@ -28,12 +28,10 @@ import {
Key,
Compass,
Brain,
ScrollText,
FileTerminal,
Activity,
BarChart3,
GitBranch,
Monitor,
Bot,
AlertTriangle,
} from 'lucide-react';
@@ -92,8 +90,6 @@ const menuItems: MenuItem[] = [
{ id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' },
{ id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center' },
{ id: 'logs-monitor', name: '日志能力', icon: FileCode, path: '/logs-monitor' },
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/grafana' },
{ id: 'agent-alerts', name: 'Agent告警', icon: AlertTriangle, path: '/agent-alerts' },
],
},
@@ -120,7 +116,6 @@ const menuItems: MenuItem[] = [
{ id: 'ai-script-generator', name: 'AI脚本生成', icon: FileCode, path: '/ai-script-generator' },
{ id: 'ai-fault-diagnosis', name: '故障智能诊断', icon: Activity, path: '/ai-fault-diagnosis' },
{ id: 'ai-predictive', name: 'AI预测分析', icon: BarChart3, path: '/ai-predictive' },
{ id: 'sql-audit', name: 'SQL审计', icon: ScrollText, path: '/sql-audit' },
],
},
{
@@ -121,7 +121,7 @@ const AlertCenter: React.FC = () => {
const fetchRules = async () => {
try {
const res = await alertCenterApi.getRules();
setRules(res.data || []);
setRules(res.list || []);
} catch (error) {
toast.error('获取告警规则失败');
}
+12 -71
View File
@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -62,82 +63,13 @@ export function BatchImport() {
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
// 模拟文件解析
generateMockRecords();
toast.info('文件解析功能开发中');
}, []);
const generateMockRecords = () => {
const mockRecords: ImportRecord[] = [
{
id: '1',
hostname: 'web-server-03',
ip: '192.168.1.111',
os: 'CentOS 7.9',
cpu: '16',
memory: '64',
disk: '500',
region: '华北-北京',
tags: 'web,production',
status: 'valid',
},
{
id: '2',
hostname: 'app-server-03',
ip: '192.168.1.112',
os: 'Ubuntu 22.04 LTS',
cpu: '32',
memory: '128',
disk: '1000',
region: '华东-上海',
tags: 'app,production',
status: 'valid',
},
{
id: '3',
hostname: 'cache-server-02',
ip: '192.168.1.113',
os: 'Ubuntu 20.04 LTS',
cpu: '16',
memory: '64',
disk: '200',
region: '华南-广州',
tags: 'cache,redis',
status: 'valid',
},
{
id: '4',
hostname: '',
ip: '192.168.1.114',
os: 'CentOS 8.5',
cpu: '8',
memory: '32',
disk: '500',
region: '华北-北京',
tags: 'test',
status: 'invalid',
message: '主机名不能为空',
},
{
id: '5',
hostname: 'invalid-server',
ip: 'invalid-ip',
os: 'Ubuntu 22.04 LTS',
cpu: '16',
memory: '64',
disk: '500',
region: '华东-上海',
tags: 'dev',
status: 'invalid',
message: 'IP地址格式不正确',
},
];
setRecords(mockRecords);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
generateMockRecords();
toast.info('文件解析功能开发中');
}
};
@@ -238,6 +170,15 @@ export function BatchImport() {
</CardContent>
</Card>
{/* Empty State */}
{records.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
<Server className="h-10 w-10 mb-3 opacity-30" />
<p>暂无导入记录</p>
<p className="text-xs mt-1">上传文件后将在此预览数据</p>
</div>
)}
{/* Import Stats */}
{records.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+222 -334
View File
@@ -1,9 +1,8 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
@@ -23,135 +22,73 @@ import {
Server,
Plus,
Search,
MapPin,
Zap,
Grid3X3,
List,
MapPin,
Cpu,
MemoryStick,
HardDrive,
Network,
Router,
Shield,
Database,
Monitor,
Edit,
Trash2,
Eye,
ArrowUp,
ArrowDown,
Edit,
} from 'lucide-react';
import { cabinets, devices } from '@/mocks/data';
import type { Cabinet, Device } from '@/types';
import { serverApi } from '@/services/api';
import type { Server as ServerType } from '@/types';
export function CabinetView() {
const [viewMode, setViewMode] = useState<'room' | 'cabinet' | 'list'>('room');
const [selectedRoom, setSelectedRoom] = useState<string>('all');
const [selectedCabinet, setSelectedCabinet] = useState<Cabinet | null>(null);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [servers, setServers] = useState<ServerType[]>([]);
const [loading, setLoading] = useState(true);
const [selectedServer, setSelectedServer] = useState<ServerType | null>(null);
const rooms = Array.from(new Set(cabinets.map((c) => c.room)));
// 拉取服务器列表 // Logan.Li
const fetchServers = async () => {
setLoading(true);
try {
const resp = await serverApi.getServers({ page: 1, page_size: 200 });
setServers((resp?.list ?? []) as ServerType[]);
} catch {
setServers([]);
} finally {
setLoading(false);
}
};
const filteredCabinets = cabinets.filter((cabinet) => {
const matchesRoom = selectedRoom === 'all' || cabinet.room === selectedRoom;
const matchesSearch = cabinet.name.toLowerCase().includes(searchQuery.toLowerCase());
return matchesRoom && matchesSearch;
useEffect(() => {
fetchServers();
}, []);
const filteredServers = servers.filter((srv) => {
const matchesStatus = statusFilter === 'all' || srv.status === statusFilter;
const matchesSearch =
srv.hostname?.toLowerCase().includes(searchQuery.toLowerCase()) ||
srv.ip?.toLowerCase().includes(searchQuery.toLowerCase());
return matchesStatus && matchesSearch;
});
const getCabinetDevices = (cabinetId: string) => {
return devices.filter((d) => d.cabinetId === cabinetId).sort((a, b) => b.startU - a.startU);
};
const getDeviceIcon = (type: Device['type']) => {
const icons = {
server: <Server className="h-4 w-4" />,
switch: <Network className="h-4 w-4" />,
router: <Router className="h-4 w-4" />,
firewall: <Shield className="h-4 w-4" />,
storage: <Database className="h-4 w-4" />,
other: <Monitor className="h-4 w-4" />,
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
running: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10',
stopped: 'border-red-500/30 text-red-400 bg-red-500/10',
maintenance: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
offline: 'border-slate-500/30 text-slate-400 bg-slate-500/10',
};
return icons[type];
return colors[status] ?? colors.offline;
};
const getDeviceColor = (type: Device['type']) => {
const colors = {
server: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
switch: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
router: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
firewall: 'bg-red-500/20 text-red-400 border-red-500/30',
storage: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
other: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
const getStatusText = (status: string) => {
const map: Record<string, string> = {
running: '运行中',
stopped: '已停止',
maintenance: '维护中',
offline: '离线',
};
return colors[type];
return map[status] ?? status;
};
const getStatusColor = (status: Cabinet['status']) => {
const colors = {
active: 'border-emerald-500/50 bg-emerald-500/5',
empty: 'border-slate-600 bg-slate-800/30',
disabled: 'border-slate-700 bg-slate-800/50 opacity-50',
};
return colors[status];
};
// 渲染机柜正面视图
const renderCabinetFront = (cabinet: Cabinet) => {
const cabinetDevices = getCabinetDevices(cabinet.id);
const uSlots: (Device | null)[] = new Array(cabinet.totalU).fill(null);
cabinetDevices.forEach((device) => {
for (let i = 0; i < device.sizeU; i++) {
const uIndex = cabinet.totalU - device.startU - i;
if (uIndex >= 0 && uIndex < cabinet.totalU) {
uSlots[uIndex] = device;
}
}
});
return (
<div className="flex gap-4">
{/* U位编号 */}
<div className="flex flex-col text-xs text-slate-500">
{Array.from({ length: cabinet.totalU }, (_, i) => (
<div key={i} className="h-6 flex items-center justify-end pr-2">
{cabinet.totalU - i}U
</div>
))}
</div>
{/* 机柜 */}
<div className="flex-1 border-2 border-slate-700 rounded-lg overflow-hidden bg-slate-900">
{uSlots.map((device, index) => {
const uNumber = cabinet.totalU - index;
if (device) {
const isFirstU = uNumber === device.startU + device.sizeU - 1;
if (!isFirstU) return null;
return (
<div
key={`device-${device.id}`}
className={`border-b border-slate-800 flex items-center px-3 cursor-pointer hover:opacity-80 transition-opacity ${getDeviceColor(device.type)}`}
style={{ height: `${device.sizeU * 24}px` }}
onClick={() => setSelectedDevice(device)}
>
{getDeviceIcon(device.type)}
<span className="ml-2 text-sm font-medium truncate">{device.name}</span>
<span className="ml-auto text-xs opacity-70">{device.ip}</span>
</div>
);
}
return (
<div
key={`empty-${index}`}
className="h-6 border-b border-slate-800/50 hover:bg-slate-800/30 transition-colors"
/>
);
})}
</div>
</div>
);
};
const getUsageColor = (usage: number) =>
usage > 80 ? 'bg-red-400' : usage > 60 ? 'bg-amber-400' : 'bg-emerald-400';
return (
<div className="space-y-6">
@@ -159,18 +96,12 @@ export function CabinetView() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">机房机柜视图</h1>
<p className="text-sm text-slate-500 mt-1">数据中心机房与机柜设备管理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<MapPin className="h-4 w-4 mr-2" />
机房管理
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
添加设备
</Button>
<p className="text-sm text-slate-500 mt-1">数据中心服务器资产概览</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
添加设备
</Button>
</div>
{/* Stats Cards */}
@@ -179,36 +110,10 @@ export function CabinetView() {
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">机房数</p>
<p className="text-2xl font-bold text-slate-100">{rooms.length}</p>
<p className="text-sm text-slate-500">服务器总数</p>
<p className="text-2xl font-bold text-slate-100">{servers.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<MapPin className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">机柜数</p>
<p className="text-2xl font-bold text-slate-100">{cabinets.length}</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
<Grid3X3 className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">设备数</p>
<p className="text-2xl font-bold text-emerald-400">{devices.length}</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Server className="h-5 w-5" />
</div>
</div>
@@ -218,13 +123,43 @@ export function CabinetView() {
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">总功率</p>
<p className="text-sm text-slate-500">运行中</p>
<p className="text-2xl font-bold text-emerald-400">
{servers.filter((s) => s.status === 'running').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Grid3X3 className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">维护中</p>
<p className="text-2xl font-bold text-amber-400">
{cabinets.reduce((acc, c) => acc + c.powerUsed, 0).toFixed(1)}kW
{servers.filter((s) => s.status === 'maintenance').length}
</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Zap className="h-5 w-5" />
<Cpu className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">离线</p>
<p className="text-2xl font-bold text-slate-400">
{servers.filter((s) => s.status === 'offline' || s.status === 'stopped').length}
</p>
</div>
<div className="p-3 rounded-lg bg-slate-500/10 text-slate-400">
<HardDrive className="h-5 w-5" />
</div>
</div>
</CardContent>
@@ -237,44 +172,34 @@ export function CabinetView() {
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索机柜或设备..."
placeholder="搜索主机名或 IP..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Select value={selectedRoom} onValueChange={setSelectedRoom}>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="选择机房" />
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部机房</SelectItem>
{rooms.map((room) => (
<SelectItem key={room} value={room}>
{room}
</SelectItem>
))}
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="running">运行中</SelectItem>
<SelectItem value="stopped">已停止</SelectItem>
<SelectItem value="maintenance">维护中</SelectItem>
<SelectItem value="offline">离线</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-1">
<Button
variant={viewMode === 'room' ? 'secondary' : 'ghost'}
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
size="sm"
className="h-7"
onClick={() => setViewMode('room')}
>
<MapPin className="h-4 w-4 mr-1" />
机房视图
</Button>
<Button
variant={viewMode === 'cabinet' ? 'secondary' : 'ghost'}
size="sm"
className="h-7"
onClick={() => setViewMode('cabinet')}
onClick={() => setViewMode('grid')}
>
<Grid3X3 className="h-4 w-4 mr-1" />
机柜视图
网格视图
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
@@ -289,176 +214,143 @@ export function CabinetView() {
</div>
{/* View Content */}
{viewMode === 'room' && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{filteredCabinets.map((cabinet) => {
const usagePercent = (cabinet.usedU / cabinet.totalU) * 100;
const powerPercent = (cabinet.powerUsed / cabinet.powerCapacity) * 100;
return (
<Card
key={cabinet.id}
className={`bg-slate-900 border-2 cursor-pointer hover:shadow-lg transition-all ${getStatusColor(cabinet.status)}`}
onClick={() => setSelectedCabinet(cabinet)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-200">{cabinet.name}</span>
{cabinet.status === 'active' && (
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
)}
</div>
<div className="space-y-2">
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>U位</span>
<span>{cabinet.usedU}/{cabinet.totalU}</span>
</div>
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
usagePercent > 80 ? 'bg-red-400' : usagePercent > 60 ? 'bg-amber-400' : 'bg-emerald-400'
}`}
style={{ width: `${usagePercent}%` }}
/>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : filteredServers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-slate-500">
<MapPin className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm">暂无服务器数据</p>
</div>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredServers.map((srv) => (
<Card
key={srv.id}
className="bg-slate-900 border-slate-800 cursor-pointer hover:border-slate-700 transition-all"
onClick={() => setSelectedServer(srv)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-blue-500/10">
<Server className="h-4 w-4 text-blue-400" />
</div>
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>功率</span>
<span>{cabinet.powerUsed}/{cabinet.powerCapacity}kW</span>
</div>
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
powerPercent > 80 ? 'bg-red-400' : powerPercent > 60 ? 'bg-amber-400' : 'bg-emerald-400'
}`}
style={{ width: `${powerPercent}%` }}
/>
</div>
<p className="text-sm font-medium text-slate-200">{srv.hostname || '--'}</p>
<p className="text-xs text-slate-500">{srv.ip || '--'}</p>
</div>
</div>
<div className="mt-3 flex items-center gap-1">
<Badge variant="outline" className={getStatusColor(srv.status)}>
{getStatusText(srv.status)}
</Badge>
</div>
<div className="space-y-2">
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" /> CPU
</span>
<span>{Math.round(srv.cpuUsage ?? 0)}%</span>
</div>
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${getUsageColor(srv.cpuUsage ?? 0)}`}
style={{ width: `${srv.cpuUsage ?? 0}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span className="flex items-center gap-1">
<MemoryStick className="h-3 w-3" /> 内存
</span>
<span>{Math.round(srv.memoryUsage ?? 0)}%</span>
</div>
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${getUsageColor(srv.memoryUsage ?? 0)}`}
style={{ width: `${srv.memoryUsage ?? 0}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" /> 磁盘
</span>
<span>{Math.round(srv.diskUsage ?? 0)}%</span>
</div>
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${getUsageColor(srv.diskUsage ?? 0)}`}
style={{ width: `${srv.diskUsage ?? 0}%` }}
/>
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1">
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
{srv.cpu ?? 0} 核
</Badge>
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
{srv.memory ?? 0} GB
</Badge>
{srv.region && (
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
{getCabinetDevices(cabinet.id).length} 设备
{srv.region}
</Badge>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{viewMode === 'cabinet' && selectedCabinet && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">{selectedCabinet.name}</CardTitle>
<CardDescription className="text-slate-500">
{selectedCabinet.room} · {selectedCabinet.usedU}/{selectedCabinet.totalU}U · {selectedCabinet.powerUsed}/{selectedCabinet.powerCapacity}kW
</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedCabinet(null)}>
返回
</Button>
</div>
</CardHeader>
<CardContent>
{renderCabinetFront(selectedCabinet)}
</CardContent>
</Card>
</div>
<div>
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 text-base">机柜信息</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-500 mb-1">机柜编号</p>
<p className="text-sm text-slate-200">{selectedCabinet.name}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">所属机房</p>
<p className="text-sm text-slate-200">{selectedCabinet.room}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">位置</p>
<p className="text-sm text-slate-200">第{selectedCabinet.row}排 第{selectedCabinet.column}列</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">状态</p>
<Badge variant="outline" className={
selectedCabinet.status === 'active'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}>
{selectedCabinet.status === 'active' ? '使用中' : selectedCabinet.status === 'empty' ? '空闲' : '停用'}
</Badge>
</div>
)}
</div>
</CardContent>
</Card>
</div>
))}
</div>
)}
{viewMode === 'list' && (
) : (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<table className="w-full">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">设备名称</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">类型</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">机柜</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">U位</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">主机名</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">IP地址</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">系统</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">区域</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">配置</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">状态</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-400">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{devices.map((device) => (
<tr key={device.id} className="hover:bg-slate-800/50">
{filteredServers.map((srv) => (
<tr key={srv.id} className="hover:bg-slate-800/50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{getDeviceIcon(device.type)}
<span className="text-sm font-medium text-slate-200">{device.name}</span>
<Server className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">{srv.hostname || '--'}</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-slate-300">{srv.ip || '--'}</td>
<td className="px-4 py-3 text-sm text-slate-300">{srv.os || '--'}</td>
<td className="px-4 py-3 text-sm text-slate-300">{srv.region || '--'}</td>
<td className="px-4 py-3 text-sm text-slate-400">
{srv.cpu ?? 0}核 / {srv.memory ?? 0}GB
</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
{device.type}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{cabinets.find((c) => c.id === device.cabinetId)?.name}
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{device.startU}U - {device.startU + device.sizeU - 1}U ({device.sizeU}U)
</td>
<td className="px-4 py-3 text-sm text-slate-300">{device.ip || '--'}</td>
<td className="px-4 py-3">
<Badge variant="outline" className={
device.status === 'online'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: device.status === 'offline'
? 'border-red-500/30 text-red-400 bg-red-500/10'
: 'border-amber-500/30 text-amber-400 bg-amber-500/10'
}>
{device.status === 'online' ? '在线' : device.status === 'offline' ? '离线' : '维护中'}
<Badge variant="outline" className={getStatusColor(srv.status)}>
{getStatusText(srv.status)}
</Badge>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedServer(srv)}
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
@@ -475,43 +367,39 @@ export function CabinetView() {
</Card>
)}
{/* Device Detail Dialog */}
<Dialog open={!!selectedDevice} onOpenChange={() => setSelectedDevice(null)}>
{/* Server Detail Dialog */}
<Dialog open={!!selectedServer} onOpenChange={() => setSelectedServer(null)}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
{selectedDevice && getDeviceIcon(selectedDevice.type)}
{selectedDevice?.name}
<Server className="h-5 w-5 text-blue-400" />
{selectedServer?.hostname}
</DialogTitle>
<DialogDescription className="text-slate-500">
{selectedDevice?.type} · {selectedDevice?.assetNo}
{selectedServer?.ip} · {selectedServer?.os}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-slate-800/50">
<div>
<p className="text-xs text-slate-500 mb-1">序列号</p>
<p className="text-sm text-slate-200">{selectedDevice?.sn || '--'}</p>
<p className="text-xs text-slate-500 mb-1">IP 地址</p>
<p className="text-sm text-slate-200">{selectedServer?.ip || '--'}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">资产编号</p>
<p className="text-sm text-slate-200">{selectedDevice?.assetNo || '--'}</p>
<p className="text-xs text-slate-500 mb-1">操作系统</p>
<p className="text-sm text-slate-200">{selectedServer?.os || '--'}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">IP地址</p>
<p className="text-sm text-slate-200">{selectedDevice?.ip || '--'}</p>
<p className="text-xs text-slate-500 mb-1">配置</p>
<p className="text-sm text-slate-200">
{selectedServer?.cpu ?? 0} 核 / {selectedServer?.memory ?? 0} GB
</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">状态</p>
<Badge variant="outline" className={
selectedDevice?.status === 'online'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: selectedDevice?.status === 'offline'
? 'border-red-500/30 text-red-400 bg-red-500/10'
: 'border-amber-500/30 text-amber-400 bg-amber-500/10'
}>
{selectedDevice?.status === 'online' ? '在线' : selectedDevice?.status === 'offline' ? '离线' : '维护中'}
<Badge variant="outline" className={selectedServer ? getStatusColor(selectedServer.status) : ''}>
{selectedServer ? getStatusText(selectedServer.status) : ''}
</Badge>
</div>
</div>
@@ -35,21 +35,13 @@ const cloudProviders = [
{ id: 'azure', name: 'Azure', color: 'bg-blue-600', icon: 'AZ' },
];
// Mock cloud servers data
const mockCloudServers = [
{ id: 'ecs-001', name: 'i-bp1a2b3c4d5e6f7g8', hostname: 'web-server-01', ip: '47.98.123.45', region: 'cn-hangzhou', status: 'running', os: 'CentOS 7.9', spec: '2核4G' },
{ id: 'ecs-002', name: 'i-bp2b3c4d5e6f7g8h9', hostname: 'web-server-02', ip: '47.98.123.46', region: 'cn-hangzhou', status: 'running', os: 'CentOS 7.9', spec: '2核4G' },
{ id: 'ecs-003', name: 'i-bp3c4d5e6f7g8h9i0', hostname: 'app-server-01', ip: '47.98.123.47', region: 'cn-shanghai', status: 'stopped', os: 'Ubuntu 20.04', spec: '4核8G' },
{ id: 'ecs-004', name: 'i-bp4d5e6f7g8h9i0j1', hostname: 'db-server-01', ip: '47.98.123.48', region: 'cn-beijing', status: 'running', os: 'CentOS 8.2', spec: '8核16G' },
{ id: 'ecs-005', name: 'i-bp5e6f7g8h9i0j1k2', hostname: 'cache-server-01', ip: '47.98.123.49', region: 'cn-hangzhou', status: 'running', os: 'CentOS 7.9', spec: '2核8G' },
];
export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
const [cloudAccounts, setCloudAccounts] = useState<any[]>([]);
const [step, setStep] = useState(1);
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedAccount, setSelectedAccount] = useState<string>('');
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [cloudServers, setCloudServers] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const loadAccounts = async () => {
@@ -73,7 +65,11 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
if (step < 3) {
if (step === 2) {
setIsLoading(true);
setTimeout(() => setIsLoading(false), 1000);
// 云主机发现功能尚未实现,暂以空列表展示
setTimeout(() => {
setCloudServers([]);
setIsLoading(false);
}, 600);
}
setStep(step + 1);
}
@@ -94,10 +90,10 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
};
const handleSelectAll = () => {
if (selectedServers.length === mockCloudServers.length) {
if (selectedServers.length === cloudServers.length && cloudServers.length > 0) {
setSelectedServers([]);
} else {
setSelectedServers(mockCloudServers.map(s => s.id));
setSelectedServers(cloudServers.map(s => s.id));
}
};
@@ -241,8 +237,8 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
<div className="flex items-center justify-between">
<p className="text-slate-400 text-sm">请选择要导入的云主机</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={handleSelectAll}>
{selectedServers.length === mockCloudServers.length ? '取消全选' : '全选'}
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={handleSelectAll} disabled={cloudServers.length === 0}>
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? '取消全选' : '全选'}
</Button>
<Badge className="bg-blue-500/20 text-blue-400 border-0">
已选 {selectedServers.length} 台
@@ -262,7 +258,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400 w-10">
<Checkbox
checked={selectedServers.length === mockCloudServers.length && mockCloudServers.length > 0}
checked={selectedServers.length === cloudServers.length && cloudServers.length > 0}
onCheckedChange={handleSelectAll}
/>
</th>
@@ -275,35 +271,45 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{mockCloudServers.map((server) => (
<tr
key={server.id}
className={`hover:bg-slate-800/50 ${selectedServers.includes(server.id) ? 'bg-blue-500/10' : ''}`}
>
<td className="px-3 py-2">
<Checkbox
checked={selectedServers.includes(server.id)}
onCheckedChange={() => handleServerToggle(server.id)}
/>
</td>
<td className="px-3 py-2 text-sm text-slate-300">{server.name}</td>
<td className="px-3 py-2 text-sm text-slate-200">{server.hostname}</td>
<td className="px-3 py-2 text-sm text-slate-400">{server.ip}</td>
<td className="px-3 py-2 text-sm text-slate-400">{server.region}</td>
<td className="px-3 py-2 text-sm text-slate-400">{server.spec}</td>
<td className="px-3 py-2">
<Badge
variant="outline"
className={server.status === 'running'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}
>
{server.status === 'running' ? '运行中' : '已停止'}
</Badge>
{cloudServers.length === 0 ? (
<tr>
<td colSpan={7} className="px-3 py-12 text-center">
<Server className="h-10 w-10 mx-auto mb-3 text-slate-600" />
<p className="text-slate-500">暂无云主机</p>
<p className="text-xs text-slate-600 mt-1">云主机发现功能尚未实现,请手动添加服务器</p>
</td>
</tr>
))}
) : (
cloudServers.map((server) => (
<tr
key={server.id}
className={`hover:bg-slate-800/50 ${selectedServers.includes(server.id) ? 'bg-blue-500/10' : ''}`}
>
<td className="px-3 py-2">
<Checkbox
checked={selectedServers.includes(server.id)}
onCheckedChange={() => handleServerToggle(server.id)}
/>
</td>
<td className="px-3 py-2 text-sm text-slate-300">{server.name}</td>
<td className="px-3 py-2 text-sm text-slate-200">{server.hostname}</td>
<td className="px-3 py-2 text-sm text-slate-400">{server.ip}</td>
<td className="px-3 py-2 text-sm text-slate-400">{server.region}</td>
<td className="px-3 py-2 text-sm text-slate-400">{server.spec}</td>
<td className="px-3 py-2">
<Badge
variant="outline"
className={server.status === 'running'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}
>
{server.status === 'running' ? '运行中' : '已停止'}
</Badge>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@@ -20,7 +20,13 @@ import {
ChevronRight,
ChevronLeft,
} from 'lucide-react';
import { regions } from '@/mocks/data';
import { regionApi } from '@/services/api';
interface RegionOption {
id: string;
name: string;
code: string;
}
interface ExcelServerImportProps {
open: boolean;
@@ -32,13 +38,10 @@ const connectionMethods = [
{ id: 'key', name: '密钥认证', description: '使用SSH密钥连接' },
];
const mockPreviewData = [
{ row: 2, hostname: 'web-server-01', ip: '192.168.1.101', port: 22, username: 'root', status: 'valid', message: '验证通过' },
{ row: 3, hostname: 'web-server-02', ip: '192.168.1.102', port: 22, username: 'root', status: 'valid', message: '验证通过' },
{ row: 4, hostname: 'db-server-01', ip: '192.168.1.201', port: 22, username: 'admin', status: 'warning', message: 'IP格式正确' },
{ row: 5, hostname: '', ip: '192.168.1.999', port: 22, username: 'root', status: 'error', message: '主机名不能为空' },
{ row: 6, hostname: 'cache-server-01', ip: '192.168.1.103', port: 22, username: 'root', status: 'valid', message: '验证通过' },
];
type PreviewRow = {
row: number; hostname: string; ip: string; port: number;
username: string; status: string; message: string;
};
export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const [step, setStep] = useState(1);
@@ -46,14 +49,40 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const [connectionMethod, setConnectionMethod] = useState<string>('password');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [previewData, setPreviewData] = useState<typeof mockPreviewData>([]);
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
const [regions, setRegions] = useState<RegionOption[]>([]);
// 对话框打开时拉取区域列表 // Logan.Li
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
try {
const resp = await regionApi.list();
if (cancelled) return;
const list = resp?.list ?? [];
setRegions(
list.map((r: any) => ({
id: String(r.id ?? ''),
name: r.name ?? '',
code: r.code ?? '',
}))
);
} catch {
if (!cancelled) setRegions([]);
}
})();
return () => {
cancelled = true;
};
}, [open]);
const handleNext = () => {
if (step === 2 && selectedFile) {
setIsUploading(true);
setTimeout(() => {
setIsUploading(false);
setPreviewData(mockPreviewData);
setPreviewData([]); // 真实解析由后端处理
setStep(step + 1);
}, 1500);
} else {
@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -11,7 +12,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { operationLogs } from '@/mocks/data';
import { auditApi } from '@/services/api';
import type { OperationLog } from '@/types';
interface SystemLogsTabProps {
@@ -19,8 +20,45 @@ interface SystemLogsTabProps {
setSearchQuery: (v: string) => void;
}
// 将后端审计日志规范化为前端 OperationLog 类型 // Logan.Li
function normalizeLog(raw: any): OperationLog {
return {
id: String(raw.id ?? ''),
userId: String(raw.user_id ?? raw.userId ?? ''),
username: raw.username ?? '',
module: raw.module ?? raw.resource ?? '',
action: raw.action ?? '',
resource: raw.resource ?? '',
ip: raw.ip ?? '',
userAgent: raw.user_agent ?? raw.userAgent ?? '',
result: raw.result === 'failure' ? 'failure' : 'success',
message: raw.message ?? raw.detail,
createdAt: raw.createdAt ?? raw.created_at ?? '',
};
}
export function SystemLogsTab({ searchQuery, setSearchQuery }: SystemLogsTabProps) {
const filteredLogs = operationLogs.filter(
const [logs, setLogs] = useState<OperationLog[]>([]);
const [loading, setLoading] = useState(true);
const fetchLogs = async () => {
setLoading(true);
try {
const resp = await auditApi.getLogs({ page: 1, page_size: 50 });
const list = resp?.list ?? [];
setLogs(list.map(normalizeLog));
} catch {
setLogs([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchLogs();
}, []);
const filteredLogs = logs.filter(
(log) =>
log.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.module.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -52,7 +90,11 @@ export function SystemLogsTab({ searchQuery, setSearchQuery }: SystemLogsTabProp
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={fetchLogs}
>
<RefreshCw className="h-4 w-4 mr-2" />
刷新
</Button>
@@ -62,48 +104,59 @@ export function SystemLogsTab({ searchQuery, setSearchQuery }: SystemLogsTabProp
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">时间</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">模块</TableHead>
<TableHead className="text-slate-400">操作</TableHead>
<TableHead className="text-slate-400">资源</TableHead>
<TableHead className="text-slate-400">IP地址</TableHead>
<TableHead className="text-slate-400">结果</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">
{new Date(log.createdAt).toLocaleString()}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200">{log.username}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{log.module}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{log.action}</span>
</TableCell>
<TableCell>
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
{log.resource}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{log.ip}</span>
</TableCell>
<TableCell>{getLogResultBadge(log.result)}</TableCell>
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<Search className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">暂无操作日志</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">时间</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">模块</TableHead>
<TableHead className="text-slate-400">操作</TableHead>
<TableHead className="text-slate-400">资源</TableHead>
<TableHead className="text-slate-400">IP地址</TableHead>
<TableHead className="text-slate-400">结果</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200">{log.username || '--'}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{log.module || '--'}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{log.action || '--'}</span>
</TableCell>
<TableCell>
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
{log.resource || '--'}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{log.ip || '--'}</span>
</TableCell>
<TableCell>{getLogResultBadge(log.result)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>