feat: 修复仪表盘数据管线 — 补充应用发布种子数据 + 修复limit参数解析 + LDAP认证集成 + User类型完善
后端改进: - 修复 dashboard/recent-alerts 的 limit 参数解析 (原代码错误地将数字解析为日期) - 为 dashboard/recent-releases 添加 limit 参数解析支持 - 新增 generateAppReleases 种子数据生成函数 (10条应用发布记录) - 修复 AlertEvent 的 rule_name/rule_id/severity 数据一致性 - 添加 go-ldap/ldap/v3 依赖并集成 LDAP 认证处理器 - 添加 seed.go 自动种子数据初始化 (admin用户+LDAP配置) - User model 添加 AuthSource/LDAPConfig 字段 前端改进: - 修复 User 类型定义以匹配后端 API (real_name/is_admin/auth_source等) - 修复 Header.tsx 的 real_name 属性引用导致 TypeScript 编译错误 - 统一 API 服务层、暗色主题样式、组件联调优化 验证: - go build ./... 编译通过 - npm run build 构建通过 - 仪表盘发布历史、告警消息等区域数据正常展示 - 健康检查 /health 正常
This commit is contained in:
1 parent
97c4207ff8
commit
ff0b268984
39 files changed
+4283
-1878
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -34,45 +34,106 @@ import {
|
||||
Eye,
|
||||
Edit,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { approvalFlows, approvalInstances } from '@/mocks/data';
|
||||
import type { ApprovalFlow as ApprovalFlowType, ApprovalInstance } from '@/types';
|
||||
import { approvalApi } from '@/services/api';
|
||||
|
||||
function parseSteps(steps: any) {
|
||||
if (Array.isArray(steps)) return steps;
|
||||
if (typeof steps === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(steps);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function ApprovalFlowManager() {
|
||||
const [activeTab, setActiveTab] = useState('instances');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [selectedInstance, setSelectedInstance] = useState<ApprovalInstance | null>(null);
|
||||
const [processes, setProcesses] = useState<any[]>([]);
|
||||
const [templates, setTemplates] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedInstance, setSelectedInstance] = useState<any | null>(null);
|
||||
|
||||
const filteredInstances = approvalInstances.filter((instance) => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [processesData, templatesData] = await Promise.all([
|
||||
approvalApi.getProcesses(),
|
||||
approvalApi.getTemplates(),
|
||||
]);
|
||||
setProcesses((processesData as any)?.list || (processesData as any) || []);
|
||||
setTemplates(templatesData || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load approval data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredInstances = processes.filter((instance) => {
|
||||
const matchesSearch =
|
||||
instance.flowName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
instance.applicantName.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
(instance.title || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(instance.applicant_name || '').toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || instance.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: ApprovalInstance['status']) => {
|
||||
const configs = {
|
||||
const getStatusBadge = (status: any) => {
|
||||
const configs: Record<string, any> = {
|
||||
pending: { label: '审批中', className: 'border-amber-500/30 text-amber-400 bg-amber-500/10', icon: Clock },
|
||||
approved: { label: '已通过', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10', icon: CheckCircle },
|
||||
rejected: { label: '已拒绝', className: 'border-red-500/30 text-red-400 bg-red-500/10', icon: XCircle },
|
||||
};
|
||||
const config = configs[status];
|
||||
const config = configs[status] || configs.pending;
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<Badge variant="outline" className={config.className}>
|
||||
<config.icon className="h-3 w-3 mr-1" />
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleApprove = async (id: any) => {
|
||||
try {
|
||||
await approvalApi.approveProcess(id);
|
||||
setSelectedInstance(null);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to approve process:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: any) => {
|
||||
try {
|
||||
await approvalApi.rejectProcess(id);
|
||||
setSelectedInstance(null);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to reject process:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟审批流程图
|
||||
const renderFlowChart = (flow: ApprovalFlowType) => {
|
||||
const renderFlowChart = (flow: any) => {
|
||||
const nodes = parseSteps(flow.steps);
|
||||
if (nodes.length === 0) {
|
||||
return <div className="text-slate-500 text-center py-4">暂无流程节点</div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto py-4">
|
||||
{flow.nodes.map((node, index) => (
|
||||
<div key={node.id} className="flex items-center">
|
||||
{nodes.map((node: any, index: number) => (
|
||||
<div key={node.id || index} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg border min-w-[100px] text-center',
|
||||
@@ -84,10 +145,12 @@ export function ApprovalFlowManager() {
|
||||
>
|
||||
<p className="text-sm font-medium">{node.name}</p>
|
||||
{node.approvers && (
|
||||
<p className="text-xs text-slate-500 mt-1">{node.approvers.join(', ')}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{Array.isArray(node.approvers) ? node.approvers.join(', ') : node.approvers}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{index < flow.nodes.length - 1 && (
|
||||
{index < nodes.length - 1 && (
|
||||
<ArrowRight className="h-4 w-4 text-slate-600 mx-2" />
|
||||
)}
|
||||
</div>
|
||||
@@ -124,7 +187,7 @@ export function ApprovalFlowManager() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">待审批</p>
|
||||
<p className="text-2xl font-bold text-amber-400">
|
||||
{approvalInstances.filter((i) => i.status === 'pending').length}
|
||||
{processes.filter((i) => i.status === 'pending').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
@@ -139,7 +202,7 @@ export function ApprovalFlowManager() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">已通过</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{approvalInstances.filter((i) => i.status === 'approved').length}
|
||||
{processes.filter((i) => i.status === 'approved').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
@@ -154,7 +217,7 @@ export function ApprovalFlowManager() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">已拒绝</p>
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{approvalInstances.filter((i) => i.status === 'rejected').length}
|
||||
{processes.filter((i) => i.status === 'rejected').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
|
||||
@@ -168,7 +231,7 @@ export function ApprovalFlowManager() {
|
||||
<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">{approvalFlows.length}</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{templates.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
@@ -233,35 +296,42 @@ export function ApprovalFlowManager() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredInstances.map((instance) => (
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={7} className="text-center text-slate-500 py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredInstances.map((instance) => (
|
||||
<TableRow key={instance.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-200">{instance.flowName}</span>
|
||||
<span className="text-sm font-medium text-slate-200">{instance.title}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-slate-300">
|
||||
<User className="h-3.5 w-3.5 text-slate-500" />
|
||||
{instance.applicantName}
|
||||
{instance.applicant_name || instance.applicant}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(instance.status)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{instance.currentNode}
|
||||
{instance.current_step || '--'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{new Date(instance.createdAt).toLocaleString()}
|
||||
{instance.created_at ? new Date(instance.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{instance.completedAt
|
||||
? new Date(instance.completedAt).toLocaleString()
|
||||
{instance.completed_at
|
||||
? new Date(instance.completed_at).toLocaleString()
|
||||
: '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
@@ -278,11 +348,21 @@ export function ApprovalFlowManager() {
|
||||
</Button>
|
||||
{instance.status === 'pending' && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-emerald-400 hover:text-emerald-400">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-emerald-400 hover:text-emerald-400"
|
||||
onClick={() => handleApprove(instance.id)}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
通过
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-red-400 hover:text-red-400">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-400 hover:text-red-400"
|
||||
onClick={() => handleReject(instance.id)}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
拒绝
|
||||
</Button>
|
||||
@@ -301,7 +381,7 @@ export function ApprovalFlowManager() {
|
||||
|
||||
<TabsContent value="flows" className="mt-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{approvalFlows.map((flow) => (
|
||||
{templates.map((flow) => (
|
||||
<Card key={flow.id} className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -318,12 +398,12 @@ export function ApprovalFlowManager() {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
flow.enabled
|
||||
flow.status === 1 || flow.status === 'active' || flow.status === true
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
|
||||
}
|
||||
>
|
||||
{flow.enabled ? '已启用' : '已停用'}
|
||||
{flow.status === 1 || flow.status === 'active' || flow.status === true ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Edit className="h-4 w-4" />
|
||||
@@ -336,8 +416,8 @@ export function ApprovalFlowManager() {
|
||||
{renderFlowChart(flow)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4 text-sm text-slate-500">
|
||||
<span>创建于 {new Date(flow.createdAt).toLocaleDateString()}</span>
|
||||
<span>{flow.nodes.length} 个节点</span>
|
||||
<span>创建于 {flow.created_at ? new Date(flow.created_at).toLocaleDateString() : '--'}</span>
|
||||
<span>{parseSteps(flow.steps).length} 个节点</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -364,11 +444,11 @@ export function ApprovalFlowManager() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5 text-blue-400" />
|
||||
{selectedInstance?.flowName}
|
||||
{selectedInstance?.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
申请人: {selectedInstance?.applicantName} · 创建于{' '}
|
||||
{selectedInstance && new Date(selectedInstance.createdAt).toLocaleString()}
|
||||
申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant} · 创建于{' '}
|
||||
{selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -388,10 +468,10 @@ export function ApprovalFlowManager() {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-200">提交申请</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{selectedInstance && new Date(selectedInstance.createdAt).toLocaleString()}
|
||||
{selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-1">申请人: {selectedInstance?.applicantName}</p>
|
||||
<p className="text-sm text-slate-400 mt-1">申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -418,10 +498,10 @@ export function ApprovalFlowManager() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-200">{selectedInstance?.currentNode}</span>
|
||||
<span className="text-sm font-medium text-slate-200">{selectedInstance?.current_step || '审批节点'}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{selectedInstance?.completedAt
|
||||
? new Date(selectedInstance.completedAt).toLocaleString()
|
||||
{selectedInstance?.completed_at
|
||||
? new Date(selectedInstance.completed_at).toLocaleString()
|
||||
: '进行中'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -441,11 +521,18 @@ export function ApprovalFlowManager() {
|
||||
<DialogFooter className="gap-2">
|
||||
{selectedInstance?.status === 'pending' && (
|
||||
<>
|
||||
<Button variant="outline" className="border-red-700 text-red-400 hover:bg-red-500/10">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-700 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => handleReject(selectedInstance.id)}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
拒绝
|
||||
</Button>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => handleApprove(selectedInstance.id)}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
通过
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useUserStore } from '@/store';
|
||||
import { userApi } from '@/services/api';
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const login = useUserStore((s) => s.login);
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('123456');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await userApi.login(username, password);
|
||||
login(result.user, result.token);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: any) {
|
||||
setError(err.message || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600 mb-4">
|
||||
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17v-2a4 4 0 014-4h2m4-2H5a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2V7a2 2 0 00-2-2z M7 7h.01M11 7h.01M15 7h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">AIOps 智能运维平台</h1>
|
||||
<p className="text-slate-400 mt-2 text-sm">EZOps Platform</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="bg-slate-900/60 rounded-xl p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-slate-800 text-white rounded-lg px-4 py-2.5 border border-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-colors"
|
||||
placeholder="请输入用户名"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-800 text-white rounded-lg px-4 py-2.5 border border-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-colors"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg px-4 py-2.5 transition-colors"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-slate-500 text-xs mt-6">
|
||||
默认账号 admin / 123456
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useUserStore((s) => s.isAuthenticated);
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -41,30 +41,66 @@ import {
|
||||
Edit,
|
||||
Copy,
|
||||
Download,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { cronJobs, scripts } from '@/mocks/data';
|
||||
import type { CronJob, Script } from '@/types';
|
||||
import { cronJobApi, scriptApi } from '@/services/api';
|
||||
|
||||
function parseParams(params: any) {
|
||||
if (Array.isArray(params)) return params;
|
||||
if (typeof params === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(params);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function AutomationManager() {
|
||||
const [activeTab, setActiveTab] = useState('cronjobs');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedScript, setSelectedScript] = useState<Script | null>(null);
|
||||
const [cronJobs, setCronJobs] = useState<any[]>([]);
|
||||
const [scripts, setScripts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedScript, setSelectedScript] = useState<any | null>(null);
|
||||
const [isExecuteDialogOpen, setIsExecuteDialogOpen] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [jobsData, scriptsData] = await Promise.all([
|
||||
cronJobApi.getJobs(),
|
||||
scriptApi.getScripts(),
|
||||
]);
|
||||
setCronJobs(jobsData.list || []);
|
||||
setScripts(scriptsData.list || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load automation data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredJobs = cronJobs.filter(
|
||||
(job) =>
|
||||
job.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
job.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
(job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(job.description || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredScripts = scripts.filter(
|
||||
(script) =>
|
||||
script.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
script.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
(script.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(script.description || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getJobStatusBadge = (status: CronJob['status']) => {
|
||||
return status === 'running' ? (
|
||||
const getJobStatusBadge = (status: any) => {
|
||||
return status === 1 ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
运行中
|
||||
@@ -77,19 +113,28 @@ export function AutomationManager() {
|
||||
);
|
||||
};
|
||||
|
||||
const getLanguageBadge = (language: Script['language']) => {
|
||||
const colors = {
|
||||
const getLanguageBadge = (language: any) => {
|
||||
const colors: Record<string, string> = {
|
||||
bash: 'border-green-500/30 text-green-400 bg-green-500/10',
|
||||
python: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
|
||||
powershell: 'border-cyan-500/30 text-cyan-400 bg-cyan-500/10',
|
||||
};
|
||||
return (
|
||||
<Badge variant="outline" className={colors[language]}>
|
||||
{language.toUpperCase()}
|
||||
<Badge variant="outline" className={colors[language] || colors.bash}>
|
||||
{(language || '').toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleExecuteScript = async (id: any) => {
|
||||
try {
|
||||
await scriptApi.executeScript(id);
|
||||
setIsExecuteDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to execute script:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
@@ -131,7 +176,7 @@ export function AutomationManager() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">运行中</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{cronJobs.filter((j) => j.status === 'running').length}
|
||||
{cronJobs.filter((j) => j.status === 1).length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
@@ -157,8 +202,10 @@ export function AutomationManager() {
|
||||
<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-blue-400">156</p>
|
||||
<p className="text-sm text-slate-500">累计执行</p>
|
||||
<p className="text-2xl font-bold text-blue-400">
|
||||
{cronJobs.reduce((sum, j) => sum + (j.success_count || 0) + (j.fail_count || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
|
||||
<RotateCw className="h-5 w-5" />
|
||||
@@ -181,6 +228,12 @@ export function AutomationManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
@@ -234,24 +287,29 @@ export function AutomationManager() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : '--'}
|
||||
{job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{job.nextRunAt ? new Date(job.nextRunAt).toLocaleString() : '--'}
|
||||
{job.next_run ? new Date(job.next_run).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<span className="text-emerald-400">{job.successCount}</span>
|
||||
<span className="text-emerald-400">{job.success_count || 0}</span>
|
||||
<span className="text-slate-500 mx-1">/</span>
|
||||
<span className="text-red-400">{job.failCount}</span>
|
||||
<span className="text-red-400">{job.fail_count || 0}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="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-blue-400">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400"
|
||||
onClick={() => cronJobApi.executeJob(job.id)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
@@ -290,11 +348,11 @@ export function AutomationManager() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-slate-950 rounded-lg p-3 font-mono text-xs text-slate-400 overflow-x-auto">
|
||||
<pre className="whitespace-pre-wrap">{script.content.slice(0, 200)}...</pre>
|
||||
<pre className="whitespace-pre-wrap">{(script.content || '').slice(0, 200)}...</pre>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="flex gap-2">
|
||||
{script.params.map((param) => (
|
||||
{parseParams(script.params).map((param: any) => (
|
||||
<Badge key={param.name} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
{param.name}
|
||||
{param.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
@@ -367,7 +425,7 @@ export function AutomationManager() {
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedScript && getLanguageBadge(selectedScript.language)}
|
||||
<span className="text-sm text-slate-500">
|
||||
更新于 {selectedScript && new Date(selectedScript.updatedAt).toLocaleDateString()}
|
||||
更新于 {selectedScript && selectedScript.updated_at ? new Date(selectedScript.updated_at).toLocaleDateString() : '--'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -375,11 +433,11 @@ export function AutomationManager() {
|
||||
<pre className="whitespace-pre">{selectedScript?.content}</pre>
|
||||
</div>
|
||||
|
||||
{selectedScript && selectedScript.params.length > 0 && (
|
||||
{selectedScript && parseParams(selectedScript.params).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">参数</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedScript.params.map((param) => (
|
||||
{parseParams(selectedScript.params).map((param: any) => (
|
||||
<div key={param.name} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
|
||||
<code className="text-sm text-slate-300">{param.name}</code>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
@@ -444,7 +502,7 @@ export function AutomationManager() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedScript?.params.map((param) => (
|
||||
{selectedScript && parseParams(selectedScript.params).map((param: any) => (
|
||||
<div key={param.name}>
|
||||
<label className="text-sm font-medium text-slate-300">
|
||||
{param.name}
|
||||
@@ -463,7 +521,7 @@ export function AutomationManager() {
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsExecuteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => selectedScript && handleExecuteScript(selectedScript.id)}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
开始执行
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -49,123 +49,46 @@ import {
|
||||
Eye,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { cronJobs } from '@/mocks/data';
|
||||
|
||||
interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
schedule: string;
|
||||
command: string;
|
||||
status: 'enabled' | 'disabled';
|
||||
lastRun: string;
|
||||
lastStatus: 'success' | 'failed' | 'running' | 'never';
|
||||
nextRun: string;
|
||||
targetServers: string[];
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
const mockCronJobs: CronJob[] = [
|
||||
{
|
||||
id: 'cron-001',
|
||||
name: '数据库备份',
|
||||
description: '每日凌晨2点备份数据库',
|
||||
schedule: '0 2 * * *',
|
||||
command: '/scripts/backup-db.sh --type=full',
|
||||
status: 'enabled',
|
||||
lastRun: '2025-04-14 02:00:00',
|
||||
lastStatus: 'success',
|
||||
nextRun: '2025-04-15 02:00:00',
|
||||
targetServers: ['db-server-01', 'db-server-02'],
|
||||
createdAt: '2025-01-15',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'cron-002',
|
||||
name: '日志清理',
|
||||
description: '每周日凌晨3点清理过期日志',
|
||||
schedule: '0 3 * * 0',
|
||||
command: '/scripts/clean-logs.sh --days=30',
|
||||
status: 'enabled',
|
||||
lastRun: '2025-04-13 03:00:00',
|
||||
lastStatus: 'success',
|
||||
nextRun: '2025-04-20 03:00:00',
|
||||
targetServers: ['all'],
|
||||
createdAt: '2025-01-20',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'cron-003',
|
||||
name: '健康检查',
|
||||
description: '每5分钟执行健康检查',
|
||||
schedule: '*/5 * * * *',
|
||||
command: '/scripts/health-check.sh',
|
||||
status: 'enabled',
|
||||
lastRun: '2025-04-14 11:55:00',
|
||||
lastStatus: 'running',
|
||||
nextRun: '2025-04-14 12:00:00',
|
||||
targetServers: ['web-server-01', 'web-server-02', 'app-server-01'],
|
||||
createdAt: '2025-02-01',
|
||||
createdBy: 'ops01',
|
||||
},
|
||||
{
|
||||
id: 'cron-004',
|
||||
name: '数据同步',
|
||||
description: '每小时同步数据到备份服务器',
|
||||
schedule: '0 * * * *',
|
||||
command: '/scripts/sync-data.sh --target=backup',
|
||||
status: 'disabled',
|
||||
lastRun: '2025-04-14 10:00:00',
|
||||
lastStatus: 'failed',
|
||||
nextRun: '-',
|
||||
targetServers: ['app-server-01'],
|
||||
createdAt: '2025-02-15',
|
||||
createdBy: 'ops02',
|
||||
},
|
||||
{
|
||||
id: 'cron-005',
|
||||
name: '生成报表',
|
||||
description: '每周一上午9点生成业务报表',
|
||||
schedule: '0 9 * * 1',
|
||||
command: '/scripts/generate-report.sh --type=weekly',
|
||||
status: 'enabled',
|
||||
lastRun: '2025-04-07 09:00:00',
|
||||
lastStatus: 'success',
|
||||
nextRun: '2025-04-21 09:00:00',
|
||||
targetServers: ['report-server'],
|
||||
createdAt: '2025-03-01',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
];
|
||||
|
||||
const executionLogs = [
|
||||
{ id: 1, jobName: '数据库备份', startTime: '2025-04-14 02:00:00', endTime: '2025-04-14 02:15:30', duration: '15m 30s', status: 'success', output: '备份完成: 15.2GB' },
|
||||
{ id: 2, jobName: '健康检查', startTime: '2025-04-14 11:55:00', endTime: '2025-04-14 11:55:05', duration: '5s', status: 'success', output: '所有服务正常' },
|
||||
{ id: 3, jobName: '数据同步', startTime: '2025-04-14 10:00:00', endTime: '2025-04-14 10:05:23', duration: '5m 23s', status: 'failed', output: '连接超时' },
|
||||
{ id: 4, jobName: '日志清理', startTime: '2025-04-13 03:00:00', endTime: '2025-04-13 03:02:15', duration: '2m 15s', status: 'success', output: '清理完成: 2.3GB' },
|
||||
];
|
||||
import { cronJobApi } from '@/services/api';
|
||||
|
||||
export function CronJobManager() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isLogDialogOpen, setIsLogDialogOpen] = useState(false);
|
||||
const [selectedJob, setSelectedJob] = useState<CronJob | null>(null);
|
||||
const [selectedJob, setSelectedJob] = useState<any | null>(null);
|
||||
const [newJob, setNewJob] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
schedule: '',
|
||||
cron: '',
|
||||
command: '',
|
||||
targetServers: '',
|
||||
});
|
||||
|
||||
const filteredJobs = mockCronJobs.filter(
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await cronJobApi.getJobs();
|
||||
setJobs(data.list || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load cron jobs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredJobs = jobs.filter(
|
||||
(job) =>
|
||||
job.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
job.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
(job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(job.description || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: CronJob['lastStatus']) => {
|
||||
const getStatusBadge = (status: any) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
@@ -208,9 +131,47 @@ export function CronJobManager() {
|
||||
return patterns[schedule] || schedule;
|
||||
};
|
||||
|
||||
const handleCreateJob = () => {
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewJob({ name: '', description: '', schedule: '', command: '', targetServers: '' });
|
||||
const handleCreateJob = async () => {
|
||||
try {
|
||||
await cronJobApi.createJob({
|
||||
name: newJob.name,
|
||||
description: newJob.description,
|
||||
cron: newJob.cron,
|
||||
command: newJob.command,
|
||||
});
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewJob({ name: '', description: '', cron: '', command: '', targetServers: '' });
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create cron job:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleJob = async (id: any) => {
|
||||
try {
|
||||
await cronJobApi.toggleJob(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle job:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteJob = async (id: any) => {
|
||||
try {
|
||||
await cronJobApi.executeJob(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to execute job:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteJob = async (id: any) => {
|
||||
try {
|
||||
await cronJobApi.deleteJob(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete job:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -238,30 +199,28 @@ export function CronJobManager() {
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">任务总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{mockCronJobs.length}</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{jobs.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">已启用</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{mockCronJobs.filter((j) => j.status === 'enabled').length}
|
||||
{jobs.filter((j) => j.status === 1).length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">执行中</p>
|
||||
<p className="text-2xl font-bold text-blue-400">
|
||||
{mockCronJobs.filter((j) => j.lastStatus === 'running').length}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-blue-400">--</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">今日失败</p>
|
||||
<p className="text-slate-400 text-sm">累计失败</p>
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{mockCronJobs.filter((j) => j.lastStatus === 'failed').length}
|
||||
{jobs.reduce((sum, j) => sum + (j.fail_count || 0), 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -280,6 +239,12 @@ export function CronJobManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Job List */}
|
||||
<div className="space-y-3">
|
||||
{filteredJobs.map((job) => (
|
||||
@@ -292,20 +257,20 @@ export function CronJobManager() {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
job.status === 'enabled'
|
||||
job.status === 1
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
|
||||
}
|
||||
>
|
||||
{job.status === 'enabled' ? '已启用' : '已禁用'}
|
||||
{job.status === 1 ? '已启用' : '已禁用'}
|
||||
</Badge>
|
||||
{getStatusBadge(job.lastStatus)}
|
||||
{getStatusBadge(job.last_status)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-1">{job.description}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm">
|
||||
<span className="flex items-center gap-1 text-slate-400">
|
||||
<Clock className="h-3 w-3" />
|
||||
{parseCron(job.schedule)}
|
||||
{parseCron(job.cron)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-slate-400">
|
||||
<Terminal className="h-3 w-3" />
|
||||
@@ -313,17 +278,30 @@ export function CronJobManager() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
<span>上次执行: {job.lastRun}</span>
|
||||
<span>下次执行: {job.nextRun}</span>
|
||||
<span>目标: {job.targetServers.join(', ')}</span>
|
||||
<span>上次执行: {job.last_run ? new Date(job.last_run).toLocaleString() : '--'}</span>
|
||||
<span>下次执行: {job.next_run ? new Date(job.next_run).toLocaleString() : '--'}</span>
|
||||
<span>成功: {job.success_count || 0} / 失败: {job.fail_count || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20"
|
||||
onClick={() => handleExecuteJob(job.id)}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
立即执行
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400"
|
||||
onClick={() => handleToggleJob(job.id)}
|
||||
>
|
||||
{job.status === 1 ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -335,6 +313,7 @@ export function CronJobManager() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
onClick={() => handleDeleteJob(job.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -375,12 +354,12 @@ export function CronJobManager() {
|
||||
<Label className="text-slate-300">Cron 表达式</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newJob.schedule}
|
||||
onChange={(e) => setNewJob({ ...newJob, schedule: e.target.value })}
|
||||
value={newJob.cron}
|
||||
onChange={(e) => setNewJob({ ...newJob, cron: e.target.value })}
|
||||
placeholder="0 2 * * *"
|
||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
||||
/>
|
||||
<Select onValueChange={(v) => setNewJob({ ...newJob, schedule: v })}>
|
||||
<Select onValueChange={(v) => setNewJob({ ...newJob, cron: v })}>
|
||||
<SelectTrigger className="w-48 bg-slate-800 border-slate-700 text-slate-100 mt-1">
|
||||
<SelectValue placeholder="快速选择" />
|
||||
</SelectTrigger>
|
||||
@@ -433,39 +412,45 @@ export function CronJobManager() {
|
||||
<DialogDescription className="text-slate-500">定时任务执行记录</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-700">
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{executionLogs.map((log) => (
|
||||
<TableRow key={log.id} className="border-slate-700">
|
||||
<TableCell className="text-slate-200">{log.jobName}</TableCell>
|
||||
<TableCell className="text-slate-400">{log.startTime}</TableCell>
|
||||
<TableCell className="text-slate-400">{log.duration}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
log.status === 'success'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
}
|
||||
>
|
||||
{log.status === 'success' ? '成功' : '失败'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400 text-sm">{log.output}</TableCell>
|
||||
{jobs.length === 0 ? (
|
||||
<div className="text-center text-slate-500 py-8">暂无执行记录</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-700">
|
||||
<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>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map((job) => (
|
||||
<TableRow key={job.id} className="border-slate-700">
|
||||
<TableCell className="text-slate-200">{job.name}</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
|
||||
</TableCell>
|
||||
<TableCell className="text-emerald-400">{job.success_count || 0}</TableCell>
|
||||
<TableCell className="text-red-400">{job.fail_count || 0}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
(job.fail_count || 0) > 0
|
||||
? 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
}
|
||||
>
|
||||
{(job.fail_count || 0) > 0 ? '有失败' : '正常'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit2,
|
||||
Trash2,
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Key,
|
||||
Cloud,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { cloudAccounts } from '@/mocks/data';
|
||||
|
||||
interface CloudAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
accessKey: string;
|
||||
status: 'active' | 'inactive' | 'error';
|
||||
regionCount: number;
|
||||
serverCount: number;
|
||||
createdAt: string;
|
||||
lastSync: string;
|
||||
}
|
||||
import { cloudApi } from '@/services/api';
|
||||
|
||||
const providerIcons: Record<string, string> = {
|
||||
aws: 'AWS',
|
||||
@@ -45,21 +33,50 @@ const providerColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function CloudAccountManager() {
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('all');
|
||||
|
||||
const filteredAccounts = cloudAccounts.filter(account => {
|
||||
const matchesSearch = account.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
account.accessKey.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await cloudApi.getAccounts();
|
||||
setAccounts(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载云账号列表失败:', e);
|
||||
setAccounts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await cloudApi.deleteAccount(id);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error('删除云账号失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAccounts = accounts.filter(account => {
|
||||
const accessKey = account.access_key || account.accessKey || '';
|
||||
const matchesSearch = (account.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
accessKey.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesProvider = selectedProvider === 'all' || account.provider === selectedProvider;
|
||||
return matchesSearch && matchesProvider;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: cloudAccounts.length,
|
||||
active: cloudAccounts.filter(a => a.status === 'active').length,
|
||||
inactive: cloudAccounts.filter(a => a.status === 'inactive').length,
|
||||
error: cloudAccounts.filter(a => a.status === 'error').length,
|
||||
total: accounts.length,
|
||||
active: accounts.filter(a => a.status === 'active').length,
|
||||
inactive: accounts.filter(a => a.status === 'inactive').length,
|
||||
error: accounts.filter(a => a.status === 'error').length,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -70,10 +87,16 @@ export function CloudAccountManager() {
|
||||
<h1 className="text-2xl font-bold text-slate-100">云账号管理</h1>
|
||||
<p className="text-slate-400 mt-1">管理多云平台的访问凭证和账号信息</p>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加账号
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={loadData} className="border-slate-700 text-slate-300">
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加账号
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -158,74 +181,102 @@ export function CloudAccountManager() {
|
||||
</div>
|
||||
|
||||
{/* Account List */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{filteredAccounts.map((account) => (
|
||||
<Card key={account.id} className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${providerColors[account.provider]} flex items-center justify-center text-white font-bold text-xs`}>
|
||||
{providerIcons[account.provider]?.slice(0, 2) || account.provider.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-100">{account.name}</h3>
|
||||
<p className="text-sm text-slate-400 mt-0.5">{providerIcons[account.provider]}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Badge variant="secondary" className="bg-slate-700 text-slate-300">
|
||||
<Key className="h-3 w-3 mr-1" />
|
||||
{account.accessKey.slice(0, 8)}...
|
||||
</Badge>
|
||||
{account.status === 'active' && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
正常
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'inactive' && (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 border-0">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
已停用
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'error' && (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0">
|
||||
<Key className="h-3 w-3 mr-1" />
|
||||
异常
|
||||
</Badge>
|
||||
)}
|
||||
{loading ? (
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-12 text-center text-slate-500">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
|
||||
加载中...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredAccounts.length === 0 ? (
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-12 text-center text-slate-500">暂无云账号数据</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{filteredAccounts.map((account) => {
|
||||
const accessKey = account.access_key || account.accessKey || '';
|
||||
const regionCount = account.region_count ?? account.regionCount ?? 0;
|
||||
const serverCount = account.server_count ?? account.serverCount ?? 0;
|
||||
const lastSync = account.last_sync || account.lastSync || account.created_at || '-';
|
||||
return (
|
||||
<Card key={account.id} className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${providerColors[account.provider] || 'bg-blue-500'} flex items-center justify-center text-white font-bold text-xs`}>
|
||||
{providerIcons[account.provider]?.slice(0, 2) || (account.provider || '').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-100">{account.name}</h3>
|
||||
<p className="text-sm text-slate-400 mt-0.5">{providerIcons[account.provider]}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
{accessKey && (
|
||||
<Badge variant="secondary" className="bg-slate-700 text-slate-300">
|
||||
<Key className="h-3 w-3 mr-1" />
|
||||
{accessKey.slice(0, 8)}...
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'active' && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
正常
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'inactive' && (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 border-0">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
已停用
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'error' && (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0">
|
||||
<Key className="h-3 w-3 mr-1" />
|
||||
异常
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
onClick={() => handleDelete(account.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">区域数</p>
|
||||
<p className="text-lg font-medium text-slate-100">{account.regionCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">服务器数</p>
|
||||
<p className="text-lg font-medium text-slate-100">{account.serverCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">最后同步</p>
|
||||
<p className="text-sm text-slate-300">{account.lastSync}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">区域数</p>
|
||||
<p className="text-lg font-medium text-slate-100">{regionCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">服务器数</p>
|
||||
<p className="text-lg font-medium text-slate-100">{serverCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">最后同步</p>
|
||||
<p className="text-sm text-slate-300">
|
||||
{lastSync !== '-' ? new Date(lastSync).toLocaleString() : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit2,
|
||||
Trash2,
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Globe,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
ExternalLink
|
||||
ExternalLink,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { domains } from '@/mocks/data';
|
||||
|
||||
interface Domain {
|
||||
id: string;
|
||||
name: string;
|
||||
registrar: string;
|
||||
status: 'active' | 'expired' | 'pending';
|
||||
expireDate: string;
|
||||
dnsProvider: string;
|
||||
recordCount: number;
|
||||
sslEnabled: boolean;
|
||||
sslExpireDate?: string;
|
||||
}
|
||||
import { cloudApi } from '@/services/api';
|
||||
|
||||
export function DomainManager() {
|
||||
const [domains, setDomains] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>('all');
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await cloudApi.getDomains();
|
||||
setDomains(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载域名列表失败:', e);
|
||||
setDomains([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await cloudApi.deleteDomain(id);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error('删除域名失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredDomains = domains.filter(domain => {
|
||||
const matchesSearch = domain.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
domain.registrar.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesSearch = (domain.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(domain.registrar || '').toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus = selectedStatus === 'all' || domain.status === selectedStatus;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
@@ -44,10 +61,11 @@ export function DomainManager() {
|
||||
active: domains.filter(d => d.status === 'active').length,
|
||||
expired: domains.filter(d => d.status === 'expired').length,
|
||||
pending: domains.filter(d => d.status === 'pending').length,
|
||||
sslEnabled: domains.filter(d => d.sslEnabled).length,
|
||||
sslEnabled: domains.filter(d => d.ssl_enabled || d.sslEnabled).length,
|
||||
};
|
||||
|
||||
const getDaysUntilExpiry = (expireDate: string) => {
|
||||
if (!expireDate) return Infinity;
|
||||
const days = Math.ceil((new Date(expireDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
return days;
|
||||
};
|
||||
@@ -60,10 +78,16 @@ export function DomainManager() {
|
||||
<h1 className="text-2xl font-bold text-slate-100">域名管理</h1>
|
||||
<p className="text-slate-400 mt-1">管理域名注册信息和DNS解析记录</p>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加域名
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={loadData} className="border-slate-700 text-slate-300">
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加域名
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -159,77 +183,101 @@ export function DomainManager() {
|
||||
</div>
|
||||
|
||||
{/* Domain List */}
|
||||
<div className="space-y-3">
|
||||
{filteredDomains.map((domain) => {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(domain.expireDate);
|
||||
return (
|
||||
<Card key={domain.id} className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center">
|
||||
<Globe className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-slate-100 text-lg">{domain.name}</h3>
|
||||
{domain.status === 'active' && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
正常
|
||||
</Badge>
|
||||
)}
|
||||
{domain.status === 'expired' && (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
已过期
|
||||
</Badge>
|
||||
)}
|
||||
{domain.status === 'pending' && (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
待审核
|
||||
</Badge>
|
||||
)}
|
||||
{domain.sslEnabled && (
|
||||
<Badge className="bg-cyan-500/20 text-cyan-400 border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
SSL
|
||||
</Badge>
|
||||
)}
|
||||
{loading ? (
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-12 text-center text-slate-500">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
|
||||
加载中...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredDomains.length === 0 ? (
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-12 text-center text-slate-500">暂无域名数据</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredDomains.map((domain) => {
|
||||
const sslEnabled = domain.ssl_enabled || domain.sslEnabled;
|
||||
const expireDate = domain.expire_date || domain.expireDate || '';
|
||||
const sslExpireDate = domain.ssl_expire_date || domain.sslExpireDate || '';
|
||||
const daysUntilExpiry = getDaysUntilExpiry(expireDate);
|
||||
return (
|
||||
<Card key={domain.id} className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center">
|
||||
<Globe className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-slate-400">
|
||||
<span>注册商: {domain.registrar}</span>
|
||||
<span>DNS: {domain.dnsProvider}</span>
|
||||
<span>解析记录: {domain.recordCount}条</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-slate-100 text-lg">{domain.name}</h3>
|
||||
{domain.status === 'active' && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
正常
|
||||
</Badge>
|
||||
)}
|
||||
{domain.status === 'expired' && (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
已过期
|
||||
</Badge>
|
||||
)}
|
||||
{domain.status === 'pending' && (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
待审核
|
||||
</Badge>
|
||||
)}
|
||||
{sslEnabled && (
|
||||
<Badge className="bg-cyan-500/20 text-cyan-400 border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
SSL
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-slate-400">
|
||||
<span>注册商: {domain.registrar || '-'}</span>
|
||||
<span>DNS: {domain.dns_provider || domain.dnsProvider || '-'}</span>
|
||||
<span>解析记录: {domain.record_count ?? domain.recordCount ?? 0}条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-slate-400">到期时间</p>
|
||||
<p className={`text-sm font-medium ${daysUntilExpiry < 30 ? 'text-red-400' : 'text-slate-200'}`}>
|
||||
{expireDate || '-'}{expireDate && ` (${daysUntilExpiry}天)`}
|
||||
{sslEnabled && sslExpireDate && (
|
||||
<span className="text-slate-500"> · SSL: {sslExpireDate}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
onClick={() => handleDelete(domain.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-slate-400">到期时间</p>
|
||||
<p className={`text-sm font-medium ${daysUntilExpiry < 30 ? 'text-red-400' : 'text-slate-200'}`}>
|
||||
{domain.expireDate} ({daysUntilExpiry}天)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Server,
|
||||
Cloud,
|
||||
@@ -37,8 +36,37 @@ import {
|
||||
BarChart,
|
||||
Bar,
|
||||
} from 'recharts';
|
||||
import { dashboardData, servers, k8sClusters, alertEvents, appReleases, domains, generateMetricsData } from '@/mocks/data';
|
||||
import type { DashboardData } from '@/types';
|
||||
import { dashboardStatsApi, serverApi, k8sApi } from '@/services/api';
|
||||
|
||||
// 云平台颜色映射
|
||||
const CLOUD_COLORS: Record<string, string> = {
|
||||
'阿里云': '#ff6a00',
|
||||
aliyun: '#ff6a00',
|
||||
AWS: '#ff9900',
|
||||
aws: '#ff9900',
|
||||
'腾讯云': '#006eff',
|
||||
tencent: '#006eff',
|
||||
'华为云': '#ff0000',
|
||||
huawei: '#ff0000',
|
||||
Azure: '#0078d4',
|
||||
azure: '#0078d4',
|
||||
};
|
||||
const FALLBACK_CLOUD_COLORS = ['#ff6a00', '#ff9900', '#006eff', '#ff0000', '#0078d4', '#10b981', '#a855f7', '#ec4899'];
|
||||
|
||||
// 根据平均值生成24小时趋势数据(无实时指标时使用)
|
||||
function generateMetricsData(avgCpu: number, avgMemory: number, avgDisk: number, points = 24) {
|
||||
const data = [];
|
||||
for (let i = points - 1; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 60 * 60 * 1000);
|
||||
data.push({
|
||||
time: `${d.getHours().toString().padStart(2, '0')}:00`,
|
||||
cpu: Number(Math.max(0, avgCpu + (Math.random() - 0.5) * 15).toFixed(1)),
|
||||
memory: Number(Math.max(0, avgMemory + (Math.random() - 0.5) * 10).toFixed(1)),
|
||||
disk: Number(Math.max(0, avgDisk + (Math.random() - 0.5) * 5).toFixed(1)),
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// 统计卡片组件
|
||||
function StatCard({
|
||||
@@ -109,24 +137,18 @@ function StatCard({
|
||||
}
|
||||
|
||||
// 成本费用卡片
|
||||
function CostOverviewCard() {
|
||||
const costData = [
|
||||
{ name: '阿里云', value: 28500, color: '#ff6a00' },
|
||||
{ name: 'AWS', value: 42000, color: '#ff9900' },
|
||||
{ name: '腾讯云', value: 15800, color: '#006eff' },
|
||||
{ name: '华为云', value: 8900, color: '#ff0000' },
|
||||
{ name: 'Azure', value: 12500, color: '#0078d4' },
|
||||
];
|
||||
function CostOverviewCard({ costStats }: { costStats: any }) {
|
||||
const cloudCosts: any[] = costStats?.cloud_costs ?? [];
|
||||
const monthTrend: any[] = costStats?.month_trend ?? [];
|
||||
|
||||
const monthlyData = [
|
||||
{ month: '1月', cost: 85000 },
|
||||
{ month: '2月', cost: 92000 },
|
||||
{ month: '3月', cost: 88000 },
|
||||
{ month: '4月', cost: 95000 },
|
||||
{ month: '5月', cost: 107700 },
|
||||
];
|
||||
|
||||
const totalCost = costData.reduce((sum, item) => sum + item.value, 0);
|
||||
const costData = cloudCosts.map((c, i) => ({
|
||||
name: c.cloud,
|
||||
value: c.cost,
|
||||
color: CLOUD_COLORS[c.cloud] || FALLBACK_CLOUD_COLORS[i % FALLBACK_CLOUD_COLORS.length],
|
||||
}));
|
||||
const monthlyData = monthTrend.map((m) => ({ month: m.month, cost: m.cost }));
|
||||
const totalCost =
|
||||
costStats?.total_cost ?? costData.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
@@ -137,7 +159,7 @@ function CostOverviewCard() {
|
||||
<CardDescription className="text-slate-500">本月各云平台费用支出</CardDescription>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-slate-100">¥{totalCost.toLocaleString()}</p>
|
||||
<p className="text-2xl font-bold text-slate-100">¥{Number(totalCost).toLocaleString()}</p>
|
||||
<p className="text-xs text-emerald-400">+12.5% 较上月</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,7 +227,7 @@ function CostOverviewCard() {
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-sm text-slate-300">{item.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-100">¥{item.value.toLocaleString()}</span>
|
||||
<span className="text-sm font-medium text-slate-100">¥{Number(item.value).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -215,8 +237,8 @@ function CostOverviewCard() {
|
||||
}
|
||||
|
||||
// 告警消息卡片
|
||||
function AlertMessagesCard() {
|
||||
const recentAlerts = alertEvents.filter(a => a.status === 'firing').slice(0, 8);
|
||||
function AlertMessagesCard({ alerts }: { alerts: any[] }) {
|
||||
const recentAlerts = alerts.filter((a) => a.status === 'firing').slice(0, 8);
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
@@ -246,7 +268,7 @@ function AlertMessagesCard() {
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-200 truncate">{alert.ruleName}</span>
|
||||
<span className="text-sm font-medium text-slate-200 truncate">{alert.rule_name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${
|
||||
@@ -263,7 +285,9 @@ function AlertMessagesCard() {
|
||||
<p className="text-xs text-slate-500 mt-1">{alert.target}</p>
|
||||
<p className="text-xs text-slate-500">{alert.message}</p>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap">{alert.startedAt}</span>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap">
|
||||
{alert.created_at ? new Date(alert.created_at).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -274,18 +298,53 @@ function AlertMessagesCard() {
|
||||
}
|
||||
|
||||
// Top5 资源使用卡片
|
||||
function TopResourceUsageCard() {
|
||||
const topCpuServers = [...servers]
|
||||
.sort((a, b) => b.cpuUsage - a.cpuUsage)
|
||||
.slice(0, 5);
|
||||
function TopResourceUsageCard({ topUsage }: { topUsage: any }) {
|
||||
const topCpuServers = (topUsage?.cpu ?? []).slice(0, 5);
|
||||
const topMemoryServers = (topUsage?.memory ?? []).slice(0, 5);
|
||||
const topDiskServers = (topUsage?.disk ?? []).slice(0, 5);
|
||||
|
||||
const topMemoryServers = [...servers]
|
||||
.sort((a, b) => b.memoryUsage - a.memoryUsage)
|
||||
.slice(0, 5);
|
||||
|
||||
const topDiskServers = [...servers]
|
||||
.sort((a, b) => b.diskUsage - a.diskUsage)
|
||||
.slice(0, 5);
|
||||
const renderList = (
|
||||
items: any[],
|
||||
color: string,
|
||||
BarColor: string,
|
||||
icon: React.ReactNode,
|
||||
label: string
|
||||
) => (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<span className="text-sm font-medium text-slate-300">{label}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => {
|
||||
const usage = Number(item.usage ?? 0);
|
||||
return (
|
||||
<div key={item.id ?? index} className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-slate-300 truncate">{item.hostname}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
usage > 80 ? 'bg-red-400' : usage > 60 ? 'bg-amber-400' : BarColor
|
||||
}`}
|
||||
style={{ width: `${usage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${
|
||||
usage > 80 ? 'text-red-400' : usage > 60 ? 'text-amber-400' : 'text-slate-400'
|
||||
}`}>
|
||||
{usage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
@@ -300,103 +359,11 @@ function TopResourceUsageCard() {
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* CPU Top5 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-slate-300">CPU</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{topCpuServers.map((server, index) => (
|
||||
<div key={server.id} className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
server.cpuUsage > 80 ? 'bg-red-400' : server.cpuUsage > 60 ? 'bg-amber-400' : 'bg-blue-400'
|
||||
}`}
|
||||
style={{ width: `${server.cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${
|
||||
server.cpuUsage > 80 ? 'text-red-400' : server.cpuUsage > 60 ? 'text-amber-400' : 'text-slate-400'
|
||||
}`}>
|
||||
{server.cpuUsage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderList(topCpuServers, 'blue', 'bg-blue-400', <Cpu className="h-4 w-4 text-blue-400" />, 'CPU')}
|
||||
{/* Memory Top5 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MemoryStick className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-sm font-medium text-slate-300">内存</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{topMemoryServers.map((server, index) => (
|
||||
<div key={server.id} className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
server.memoryUsage > 80 ? 'bg-red-400' : server.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
}`}
|
||||
style={{ width: `${server.memoryUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${
|
||||
server.memoryUsage > 80 ? 'text-red-400' : server.memoryUsage > 60 ? 'text-amber-400' : 'text-slate-400'
|
||||
}`}>
|
||||
{server.memoryUsage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderList(topMemoryServers, 'emerald', 'bg-emerald-400', <MemoryStick className="h-4 w-4 text-emerald-400" />, '内存')}
|
||||
{/* Disk Top5 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Database className="h-4 w-4 text-purple-400" />
|
||||
<span className="text-sm font-medium text-slate-300">磁盘</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{topDiskServers.map((server, index) => (
|
||||
<div key={server.id} className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
server.diskUsage > 80 ? 'bg-red-400' : server.diskUsage > 60 ? 'bg-amber-400' : 'bg-purple-400'
|
||||
}`}
|
||||
style={{ width: `${server.diskUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${
|
||||
server.diskUsage > 80 ? 'text-red-400' : server.diskUsage > 60 ? 'text-amber-400' : 'text-slate-400'
|
||||
}`}>
|
||||
{server.diskUsage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{renderList(topDiskServers, 'purple', 'bg-purple-400', <Database className="h-4 w-4 text-purple-400" />, '磁盘')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -404,17 +371,16 @@ function TopResourceUsageCard() {
|
||||
}
|
||||
|
||||
// 证书到期卡片
|
||||
function CertificateExpiryCard() {
|
||||
const expiringDomains = domains
|
||||
.filter(d => d.sslEnabled)
|
||||
.map(d => ({
|
||||
function CertificateExpiryCard({ expCerts }: { expCerts: any[] }) {
|
||||
const expiringDomains = (expCerts ?? [])
|
||||
.map((d) => ({
|
||||
...d,
|
||||
daysUntilExpiry: Math.ceil((new Date(d.sslExpireDate!).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
daysUntilExpiry: d.days_until_expiry ?? Math.ceil((new Date(d.expire_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
|
||||
}))
|
||||
.sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry);
|
||||
|
||||
const criticalCount = expiringDomains.filter(d => d.daysUntilExpiry < 30).length;
|
||||
const warningCount = expiringDomains.filter(d => d.daysUntilExpiry >= 30 && d.daysUntilExpiry < 60).length;
|
||||
const criticalCount = expiringDomains.filter((d) => d.daysUntilExpiry < 30).length;
|
||||
const warningCount = expiringDomains.filter((d) => d.daysUntilExpiry >= 30 && d.daysUntilExpiry < 60).length;
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
@@ -457,7 +423,7 @@ function CertificateExpiryCard() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{domain.name}</p>
|
||||
<p className="text-xs text-slate-500">到期: {domain.sslExpireDate}</p>
|
||||
<p className="text-xs text-slate-500">到期: {domain.expire_date}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -482,8 +448,16 @@ function CertificateExpiryCard() {
|
||||
}
|
||||
|
||||
// 资源使用率趋势卡片
|
||||
function ResourceUsageCard() {
|
||||
const data = generateMetricsData(24);
|
||||
function ResourceUsageCard({
|
||||
avgCpu,
|
||||
avgMemory,
|
||||
avgDisk,
|
||||
}: {
|
||||
avgCpu: number;
|
||||
avgMemory: number;
|
||||
avgDisk: number;
|
||||
}) {
|
||||
const data = generateMetricsData(avgCpu, avgMemory, avgDisk, 24);
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
@@ -574,7 +548,7 @@ function ResourceUsageCard() {
|
||||
}
|
||||
|
||||
// 发布历史卡片
|
||||
function ReleaseHistoryCard() {
|
||||
function ReleaseHistoryCard({ releases }: { releases: any[] }) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
@@ -591,7 +565,7 @@ function ReleaseHistoryCard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{appReleases.slice(0, 5).map((release) => (
|
||||
{releases.slice(0, 5).map((release) => (
|
||||
<div
|
||||
key={release.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-colors"
|
||||
@@ -611,7 +585,7 @@ function ReleaseHistoryCard() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-200">{release.appName}</span>
|
||||
<span className="text-sm font-medium text-slate-200">{release.app_name}</span>
|
||||
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
|
||||
{release.version}
|
||||
</Badge>
|
||||
@@ -619,7 +593,7 @@ function ReleaseHistoryCard() {
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-slate-500">{release.environment}</span>
|
||||
<span className="text-xs text-slate-500">·</span>
|
||||
<span className="text-xs text-slate-500">{release.commitId}</span>
|
||||
<span className="text-xs text-slate-500">{release.commit_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -653,7 +627,7 @@ function ReleaseHistoryCard() {
|
||||
}
|
||||
|
||||
// 服务器状态卡片
|
||||
function ServerStatusCard() {
|
||||
function ServerStatusCard({ servers }: { servers: any[] }) {
|
||||
const runningServers = servers.filter((s) => s.status === 'running').length;
|
||||
const stoppedServers = servers.filter((s) => s.status === 'stopped').length;
|
||||
const maintenanceServers = servers.filter((s) => s.status === 'maintenance').length;
|
||||
@@ -704,8 +678,8 @@ function ServerStatusCard() {
|
||||
<p className="text-xs text-slate-500">{server.ip}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-400">CPU {server.cpuUsage.toFixed(1)}%</p>
|
||||
<p className="text-xs text-slate-400">内存 {server.memoryUsage.toFixed(1)}%</p>
|
||||
<p className="text-xs text-slate-400">CPU {Number(server.cpu_usage ?? 0).toFixed(1)}%</p>
|
||||
<p className="text-xs text-slate-400">内存 {Number(server.memory_usage ?? 0).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -716,7 +690,7 @@ function ServerStatusCard() {
|
||||
}
|
||||
|
||||
// K8s集群状态卡片
|
||||
function K8sClusterCard() {
|
||||
function K8sClusterCard({ clusters }: { clusters: any[] }) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
@@ -733,46 +707,50 @@ function K8sClusterCard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{k8sClusters.map((cluster) => (
|
||||
<div key={cluster.id} className="p-3 rounded-lg bg-slate-800/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-slate-200">{cluster.name}</span>
|
||||
{clusters.map((cluster) => {
|
||||
const cpuUsage = Number(cluster.cpu_usage ?? 0);
|
||||
const memoryUsage = Number(cluster.memory_usage ?? 0);
|
||||
return (
|
||||
<div key={cluster.id} className="p-3 rounded-lg bg-slate-800/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-slate-200">{cluster.name}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${
|
||||
cluster.status === 'healthy'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: cluster.status === 'warning'
|
||||
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
}`}
|
||||
>
|
||||
{cluster.status === 'healthy' ? '健康' : cluster.status === 'warning' ? '警告' : '异常'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${
|
||||
cluster.status === 'healthy'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: cluster.status === 'warning'
|
||||
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
}`}
|
||||
>
|
||||
{cluster.status === 'healthy' ? '健康' : cluster.status === 'warning' ? '警告' : '异常'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{cluster.nodes}</div>
|
||||
<div className="text-xs text-slate-500">节点</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{cluster.pods}</div>
|
||||
<div className="text-xs text-slate-500">Pod</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{cluster.cpuUsage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-slate-500">CPU</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{cluster.memoryUsage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-slate-500">内存</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{cluster.node_count ?? cluster.nodes ?? 0}</div>
|
||||
<div className="text-xs text-slate-500">节点</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{cluster.pod_count ?? cluster.pods ?? 0}</div>
|
||||
<div className="text-xs text-slate-500">Pod</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{cpuUsage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-slate-500">CPU</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{memoryUsage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-slate-500">内存</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -780,21 +758,69 @@ function K8sClusterCard() {
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const [data, setData] = useState<DashboardData>(dashboardData);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [clusters, setClusters] = useState<any[]>([]);
|
||||
const [alerts, setAlerts] = useState<any[]>([]);
|
||||
const [releases, setReleases] = useState<any[]>([]);
|
||||
const [expCerts, setExpCerts] = useState<any[]>([]);
|
||||
|
||||
// 模拟实时数据更新
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
cpuUsage: 60 + Math.random() * 20,
|
||||
memoryUsage: 70 + Math.random() * 15,
|
||||
networkUsage: 30 + Math.random() * 30,
|
||||
}));
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsRes, serversRes, clustersRes, alertsRes, releasesRes] = await Promise.all([
|
||||
dashboardStatsApi.getStats().catch((e) => {
|
||||
console.error('Failed to fetch dashboard stats:', e);
|
||||
return null;
|
||||
}),
|
||||
serverApi.getServers({ page: 1, page_size: 100 }).catch((e) => {
|
||||
console.error('Failed to fetch servers:', e);
|
||||
return null;
|
||||
}),
|
||||
k8sApi.getClusters().catch((e) => {
|
||||
console.error('Failed to fetch k8s clusters:', e);
|
||||
return null;
|
||||
}),
|
||||
dashboardStatsApi.getAlerts(10).catch((e) => {
|
||||
console.error('Failed to fetch dashboard alerts:', e);
|
||||
return null;
|
||||
}),
|
||||
dashboardStatsApi.getReleases(5).catch((e) => {
|
||||
console.error('Failed to fetch dashboard releases:', e);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
|
||||
setStats(statsRes);
|
||||
setServers(serversRes?.list ?? []);
|
||||
setClusters(clustersRes?.list ?? []);
|
||||
setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
|
||||
setReleases(Array.isArray(releasesRes) ? releasesRes : []);
|
||||
setExpCerts(statsRes?.expiring_certs ?? []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="h-8 w-8 border-2 border-slate-700 border-t-blue-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const serverStats = stats?.server_stats;
|
||||
const k8sStats = stats?.k8s_stats;
|
||||
const alertStats = stats?.alert_stats;
|
||||
const costStats = stats?.cost_stats;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
@@ -819,8 +845,8 @@ export function Dashboard() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="服务器总数"
|
||||
value={data.serverCount}
|
||||
description={`${data.runningServers} 台运行中`}
|
||||
value={serverStats?.total ?? 0}
|
||||
description={`${serverStats?.running ?? 0} 台运行中`}
|
||||
icon={Server}
|
||||
trend="up"
|
||||
trendValue="+3 本月"
|
||||
@@ -828,8 +854,8 @@ export function Dashboard() {
|
||||
/>
|
||||
<StatCard
|
||||
title="K8s集群"
|
||||
value={data.k8sClusters}
|
||||
description={`${data.healthyClusters} 个健康`}
|
||||
value={k8sStats?.total ?? 0}
|
||||
description={`${k8sStats?.healthy ?? 0} 个健康`}
|
||||
icon={Cloud}
|
||||
trend="neutral"
|
||||
trendValue="稳定"
|
||||
@@ -837,8 +863,8 @@ export function Dashboard() {
|
||||
/>
|
||||
<StatCard
|
||||
title="待处理告警"
|
||||
value={data.alertCount}
|
||||
description={`${data.criticalAlerts} 个严重`}
|
||||
value={alertStats?.firing ?? 0}
|
||||
description={`${alertStats?.critical ?? 0} 个严重`}
|
||||
icon={Bell}
|
||||
trend="down"
|
||||
trendValue="-5 本周"
|
||||
@@ -846,7 +872,7 @@ export function Dashboard() {
|
||||
/>
|
||||
<StatCard
|
||||
title="本月云成本"
|
||||
value="¥107,700"
|
||||
value={`¥${Number(costStats?.month_cost ?? 0).toLocaleString()}`}
|
||||
description="较上月 +12.5%"
|
||||
icon={DollarSign}
|
||||
trend="up"
|
||||
@@ -856,25 +882,29 @@ export function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Resource Usage Chart */}
|
||||
<ResourceUsageCard />
|
||||
<ResourceUsageCard
|
||||
avgCpu={Number(serverStats?.avg_cpu ?? 0)}
|
||||
avgMemory={Number(serverStats?.avg_memory ?? 0)}
|
||||
avgDisk={Number(serverStats?.avg_disk ?? 0)}
|
||||
/>
|
||||
|
||||
{/* Cost & Top Resource Usage */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<CostOverviewCard />
|
||||
<TopResourceUsageCard />
|
||||
<CostOverviewCard costStats={costStats} />
|
||||
<TopResourceUsageCard topUsage={stats?.top_resource_usage} />
|
||||
</div>
|
||||
|
||||
{/* Alert Messages & Certificate Expiry */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<AlertMessagesCard />
|
||||
<CertificateExpiryCard />
|
||||
<AlertMessagesCard alerts={alerts} />
|
||||
<CertificateExpiryCard expCerts={expCerts} />
|
||||
</div>
|
||||
|
||||
{/* Bottom Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<ReleaseHistoryCard />
|
||||
<ServerStatusCard />
|
||||
<K8sClusterCard />
|
||||
<ReleaseHistoryCard releases={releases} />
|
||||
<ServerStatusCard servers={servers} />
|
||||
<K8sClusterCard clusters={clusters} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -52,25 +52,42 @@ import {
|
||||
ArrowRight,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { appReleases } from '@/mocks/data';
|
||||
import type { AppRelease } from '@/types';
|
||||
import { releaseApi } from '@/services/api';
|
||||
|
||||
export function AppReleaseManager() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [envFilter, setEnvFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [selectedRelease, setSelectedRelease] = useState<AppRelease | null>(null);
|
||||
const [releases, setReleases] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
|
||||
|
||||
const filteredReleases = appReleases.filter((release) => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await releaseApi.getReleases();
|
||||
setReleases(data.list || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load releases:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredReleases = releases.filter((release) => {
|
||||
const matchesSearch =
|
||||
release.appName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
release.version.includes(searchQuery);
|
||||
(release.app_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(release.version || '').includes(searchQuery);
|
||||
const matchesEnv = envFilter === 'all' || release.environment === envFilter;
|
||||
const matchesStatus = statusFilter === 'all' || release.status === statusFilter;
|
||||
return matchesSearch && matchesEnv && matchesStatus;
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: AppRelease['status']) => {
|
||||
const getStatusBadge = (status: any) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
@@ -119,26 +136,35 @@ export function AppReleaseManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const getEnvBadge = (env: AppRelease['environment']) => {
|
||||
const colors = {
|
||||
const getEnvBadge = (env: any) => {
|
||||
const colors: Record<string, string> = {
|
||||
dev: 'border-slate-500/30 text-slate-400 bg-slate-500/10',
|
||||
test: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
|
||||
staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
|
||||
prod: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10',
|
||||
};
|
||||
const labels = {
|
||||
const labels: Record<string, string> = {
|
||||
dev: '开发环境',
|
||||
test: '测试环境',
|
||||
staging: '预发布',
|
||||
prod: '生产环境',
|
||||
};
|
||||
return (
|
||||
<Badge variant="outline" className={colors[env]}>
|
||||
{labels[env]}
|
||||
<Badge variant="outline" className={colors[env] || colors.dev}>
|
||||
{labels[env] || env}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleRollback = async (id: any) => {
|
||||
try {
|
||||
await releaseApi.rollbackRelease(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to rollback release:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟流水线步骤
|
||||
const pipelineSteps = [
|
||||
{ name: '代码检出', status: 'success', duration: '15s' },
|
||||
@@ -178,7 +204,7 @@ export function AppReleaseManager() {
|
||||
<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">12</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{releases.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Rocket className="h-5 w-5" />
|
||||
@@ -191,7 +217,9 @@ export function AppReleaseManager() {
|
||||
<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">10</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{releases.filter((r) => r.status === 'success').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
@@ -204,7 +232,9 @@ export function AppReleaseManager() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">发布失败</p>
|
||||
<p className="text-2xl font-bold text-red-400">1</p>
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{releases.filter((r) => r.status === 'failed').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
|
||||
<XCircle className="h-5 w-5" />
|
||||
@@ -217,7 +247,11 @@ export function AppReleaseManager() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">成功率</p>
|
||||
<p className="text-2xl font-bold text-blue-400">83.3%</p>
|
||||
<p className="text-2xl font-bold text-blue-400">
|
||||
{releases.length > 0
|
||||
? `${Math.round((releases.filter((r) => r.status === 'success').length / releases.length) * 100)}%`
|
||||
: '0%'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<History className="h-5 w-5" />
|
||||
@@ -278,17 +312,23 @@ export function AppReleaseManager() {
|
||||
<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 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredReleases.map((release) => (
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={8} className="text-center text-slate-500 py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredReleases.map((release) => (
|
||||
<TableRow key={release.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Rocket className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-200">{release.appName}</span>
|
||||
<span className="text-sm font-medium text-slate-200">{release.app_name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -304,7 +344,7 @@ export function AppReleaseManager() {
|
||||
<span>{release.branch}</span>
|
||||
<span className="mx-1">·</span>
|
||||
<GitCommit className="h-3 w-3" />
|
||||
<span>{release.commitId}</span>
|
||||
<span>{release.commit_id}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -312,12 +352,7 @@ export function AppReleaseManager() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{new Date(release.startedAt).toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{release.duration ? `${Math.floor(release.duration / 60)}m ${release.duration % 60}s` : '--'}
|
||||
{release.created_at ? new Date(release.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -338,7 +373,12 @@ export function AppReleaseManager() {
|
||||
</Button>
|
||||
)}
|
||||
{release.status === 'success' && (
|
||||
<Button variant="ghost" size="sm" className="h-8 text-slate-400 hover:text-amber-400">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-slate-400 hover:text-amber-400"
|
||||
onClick={() => handleRollback(release.id)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
回滚
|
||||
</Button>
|
||||
@@ -359,13 +399,13 @@ export function AppReleaseManager() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Rocket className="h-5 w-5 text-blue-400" />
|
||||
{selectedRelease?.appName}
|
||||
{selectedRelease?.app_name}
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{selectedRelease?.version}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
流水线: {selectedRelease?.pipeline}
|
||||
环境: {selectedRelease?.environment}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -412,7 +452,7 @@ export function AppReleaseManager() {
|
||||
<h4 className="text-sm font-medium text-slate-300">构建日志</h4>
|
||||
<div className="bg-slate-950 rounded-lg p-4 font-mono text-xs text-slate-400 h-48 overflow-auto">
|
||||
<div className="space-y-1">
|
||||
<p>[10:00:00] Starting pipeline for {selectedRelease?.appName}...</p>
|
||||
<p>[10:00:00] Starting pipeline for {selectedRelease?.app_name}...</p>
|
||||
<p>[10:00:15] Checkout code from branch {selectedRelease?.branch}</p>
|
||||
<p>[10:00:30] Installing dependencies...</p>
|
||||
<p className="text-emerald-400">[10:01:15] Dependencies installed successfully</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
FileCode,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import { pipelines, pipelineExecutions, releases } from '@/mocks/data';
|
||||
import { pipelineApi } from '@/services/api';
|
||||
|
||||
interface PipelineStage {
|
||||
id: string;
|
||||
@@ -66,74 +66,6 @@ interface PipelineStage {
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
interface Pipeline {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
repository: string;
|
||||
branch: string;
|
||||
stages: PipelineStage[];
|
||||
lastRun: string;
|
||||
lastStatus: 'success' | 'failed' | 'running' | 'never';
|
||||
triggerType: 'manual' | 'auto';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const mockPipelines: Pipeline[] = [
|
||||
{
|
||||
id: 'pipe-001',
|
||||
name: 'frontend-build-and-deploy',
|
||||
description: '前端项目构建与部署',
|
||||
repository: 'https://gitlab.company.com/frontend/app.git',
|
||||
branch: 'main',
|
||||
stages: [
|
||||
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '15s', logs: ['检出代码...', '代码检出完成'] },
|
||||
{ id: 'stage-2', name: '依赖安装', status: 'success', duration: '45s', logs: ['安装依赖...', '依赖安装完成'] },
|
||||
{ id: 'stage-3', name: '代码检查', status: 'success', duration: '30s', logs: ['执行 ESLint...', '代码检查通过'] },
|
||||
{ id: 'stage-4', name: '构建打包', status: 'success', duration: '2m 15s', logs: ['开始构建...', '构建完成'] },
|
||||
{ id: 'stage-5', name: '部署到测试环境', status: 'success', duration: '30s', logs: ['部署中...', '部署完成'] },
|
||||
],
|
||||
lastRun: '2025-04-14 10:30:00',
|
||||
lastStatus: 'success',
|
||||
triggerType: 'auto',
|
||||
createdAt: '2025-01-15',
|
||||
},
|
||||
{
|
||||
id: 'pipe-002',
|
||||
name: 'backend-api-deploy',
|
||||
description: '后端API服务部署',
|
||||
repository: 'https://gitlab.company.com/backend/api.git',
|
||||
branch: 'develop',
|
||||
stages: [
|
||||
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '12s', logs: [] },
|
||||
{ id: 'stage-2', name: '单元测试', status: 'success', duration: '1m 30s', logs: [] },
|
||||
{ id: 'stage-3', name: '构建镜像', status: 'running', duration: '45s', logs: ['构建Docker镜像...'] },
|
||||
{ id: 'stage-4', name: '推送镜像', status: 'pending', duration: '-', logs: [] },
|
||||
{ id: 'stage-5', name: '部署到K8s', status: 'pending', duration: '-', logs: [] },
|
||||
],
|
||||
lastRun: '2025-04-14 11:15:00',
|
||||
lastStatus: 'running',
|
||||
triggerType: 'manual',
|
||||
createdAt: '2025-02-01',
|
||||
},
|
||||
{
|
||||
id: 'pipe-003',
|
||||
name: 'database-migration',
|
||||
description: '数据库迁移脚本执行',
|
||||
repository: 'https://gitlab.company.com/database/migrations.git',
|
||||
branch: 'main',
|
||||
stages: [
|
||||
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '10s', logs: [] },
|
||||
{ id: 'stage-2', name: '数据库备份', status: 'success', duration: '2m 00s', logs: [] },
|
||||
{ id: 'stage-3', name: '执行迁移', status: 'failed', duration: '15s', logs: ['迁移失败: 表已存在'] },
|
||||
],
|
||||
lastRun: '2025-04-14 09:00:00',
|
||||
lastStatus: 'failed',
|
||||
triggerType: 'manual',
|
||||
createdAt: '2025-01-20',
|
||||
},
|
||||
];
|
||||
|
||||
const pipelineTemplates = [
|
||||
{ id: 'template-1', name: 'Node.js 项目', description: '适用于 Node.js 前端项目', stages: ['代码检出', '依赖安装', '代码检查', '构建打包', '部署'] },
|
||||
{ id: 'template-2', name: 'Go 项目', description: '适用于 Go 后端项目', stages: ['代码检出', '单元测试', '构建', '构建镜像', '部署'] },
|
||||
@@ -141,9 +73,24 @@ const pipelineTemplates = [
|
||||
{ id: 'template-4', name: 'Python 项目', description: '适用于 Python 项目', stages: ['代码检出', '依赖安装', '代码检查', '测试', '部署'] },
|
||||
];
|
||||
|
||||
function parseStages(stages: any): PipelineStage[] {
|
||||
if (Array.isArray(stages)) return stages;
|
||||
if (typeof stages === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(stages);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
return stages.split(',').map((s, i) => ({ id: `stage-${i + 1}`, name: s.trim(), status: 'success' as const, duration: '-', logs: [] }));
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function PipelineManager() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
|
||||
const [pipelines, setPipelines] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPipeline, setSelectedPipeline] = useState<any | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
|
||||
const [isRunDialogOpen, setIsRunDialogOpen] = useState(false);
|
||||
@@ -156,13 +103,29 @@ export function PipelineManager() {
|
||||
template: '',
|
||||
});
|
||||
|
||||
const filteredPipelines = mockPipelines.filter(
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await pipelineApi.getPipelines();
|
||||
setPipelines(data.list || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load pipelines:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredPipelines = pipelines.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
(p.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(p.description || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: Pipeline['lastStatus']) => {
|
||||
const getStatusBadge = (status: any) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
@@ -209,14 +172,40 @@ export function PipelineManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePipeline = () => {
|
||||
// Mock create
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewPipeline({ name: '', description: '', repository: '', branch: 'main', template: '' });
|
||||
const handleCreatePipeline = async () => {
|
||||
try {
|
||||
await pipelineApi.createPipeline({
|
||||
name: newPipeline.name,
|
||||
description: newPipeline.description,
|
||||
repository: newPipeline.repository,
|
||||
branch: newPipeline.branch,
|
||||
});
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewPipeline({ name: '', description: '', repository: '', branch: 'main', template: '' });
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create pipeline:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunPipeline = () => {
|
||||
setIsRunDialogOpen(false);
|
||||
const handleDeletePipeline = async (id: any) => {
|
||||
try {
|
||||
await pipelineApi.deletePipeline(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete pipeline:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunPipeline = async () => {
|
||||
if (!selectedPipeline) return;
|
||||
try {
|
||||
await pipelineApi.triggerPipeline(selectedPipeline.id);
|
||||
setIsRunDialogOpen(false);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger pipeline:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -246,7 +235,7 @@ export function PipelineManager() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">流水线总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{mockPipelines.length}</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{pipelines.length}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<Workflow className="h-5 w-5 text-blue-400" />
|
||||
@@ -260,7 +249,7 @@ export function PipelineManager() {
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">运行中</p>
|
||||
<p className="text-2xl font-bold text-blue-400">
|
||||
{mockPipelines.filter((p) => p.lastStatus === 'running').length}
|
||||
{pipelines.filter((p) => p.status === 'running').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
@@ -274,7 +263,9 @@ export function PipelineManager() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">今日成功</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">12</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{pipelines.filter((p) => p.status === 'success').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
|
||||
<CheckCircle className="h-5 w-5 text-emerald-400" />
|
||||
@@ -287,7 +278,9 @@ export function PipelineManager() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">今日失败</p>
|
||||
<p className="text-2xl font-bold text-red-400">2</p>
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{pipelines.filter((p) => p.status === 'failed').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||
<XCircle className="h-5 w-5 text-red-400" />
|
||||
@@ -310,6 +303,14 @@ export function PipelineManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
) : filteredPipelines.length === 0 ? (
|
||||
<div className="text-center text-slate-500 py-12">暂无流水线数据</div>
|
||||
) : null}
|
||||
|
||||
{/* Pipeline List */}
|
||||
<div className="space-y-4">
|
||||
{filteredPipelines.map((pipeline) => (
|
||||
@@ -319,9 +320,9 @@ export function PipelineManager() {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-medium text-slate-100">{pipeline.name}</h3>
|
||||
{getStatusBadge(pipeline.lastStatus)}
|
||||
{getStatusBadge(pipeline.status)}
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{pipeline.triggerType === 'auto' ? '自动触发' : '手动触发'}
|
||||
{pipeline.trigger_type === 'auto' ? '自动触发' : '手动触发'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-1">{pipeline.description}</p>
|
||||
@@ -332,15 +333,15 @@ export function PipelineManager() {
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
上次运行: {pipeline.lastRun}
|
||||
上次运行: {pipeline.updated_at || '--'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Stages */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{pipeline.stages.map((stage, index) => (
|
||||
<div key={stage.id} className="flex items-center">
|
||||
{parseStages(pipeline.stages).map((stage: PipelineStage, index: number) => (
|
||||
<div key={stage.id || index} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
|
||||
stage.status === 'success'
|
||||
@@ -355,7 +356,7 @@ export function PipelineManager() {
|
||||
{getStageStatusIcon(stage.status)}
|
||||
<span className="ml-1">{stage.name}</span>
|
||||
</div>
|
||||
{index < pipeline.stages.length - 1 && (
|
||||
{index < parseStages(pipeline.stages).length - 1 && (
|
||||
<ChevronRight className="h-4 w-4 text-slate-600 mx-1" />
|
||||
)}
|
||||
</div>
|
||||
@@ -391,7 +392,7 @@ export function PipelineManager() {
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400" onClick={() => handleDeletePipeline(pipeline.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -513,9 +514,9 @@ export function PipelineManager() {
|
||||
</TabsList>
|
||||
<TabsContent value="stages" className="mt-4">
|
||||
<div className="space-y-2">
|
||||
{selectedPipeline.stages.map((stage, index) => (
|
||||
{parseStages(selectedPipeline.stages).map((stage: PipelineStage, index: number) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
key={stage.id || index}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-all ${
|
||||
activeStage === stage.id
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
@@ -562,7 +563,7 @@ export function PipelineManager() {
|
||||
<div>
|
||||
<Label className="text-slate-400">触发方式</Label>
|
||||
<p className="text-slate-200">
|
||||
{selectedPipeline.triggerType === 'auto' ? '自动触发' : '手动触发'}
|
||||
{selectedPipeline.trigger_type === 'auto' ? '自动触发' : '手动触发'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -54,95 +54,7 @@ import {
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { appReleases } from '@/mocks/data';
|
||||
|
||||
interface Release {
|
||||
id: string;
|
||||
appName: string;
|
||||
version: string;
|
||||
environment: 'production' | 'staging' | 'development';
|
||||
status: 'pending' | 'approved' | 'deploying' | 'success' | 'failed' | 'rolled_back';
|
||||
commitId: string;
|
||||
branch: string;
|
||||
applicant: string;
|
||||
approver?: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
deployedAt?: string;
|
||||
rollbackVersion?: string;
|
||||
}
|
||||
|
||||
const mockReleases: Release[] = [
|
||||
{
|
||||
id: 'rel-001',
|
||||
appName: '用户中心服务',
|
||||
version: 'v2.3.1',
|
||||
environment: 'production',
|
||||
status: 'success',
|
||||
commitId: 'a1b2c3d',
|
||||
branch: 'main',
|
||||
applicant: '张三',
|
||||
approver: '李四',
|
||||
description: '修复用户登录异常问题,优化性能',
|
||||
createdAt: '2025-04-14 09:00:00',
|
||||
deployedAt: '2025-04-14 10:30:00',
|
||||
},
|
||||
{
|
||||
id: 'rel-002',
|
||||
appName: '订单服务',
|
||||
version: 'v1.5.0',
|
||||
environment: 'production',
|
||||
status: 'pending',
|
||||
commitId: 'e4f5g6h',
|
||||
branch: 'release/v1.5.0',
|
||||
applicant: '王五',
|
||||
description: '新增订单批量导出功能',
|
||||
createdAt: '2025-04-14 11:00:00',
|
||||
},
|
||||
{
|
||||
id: 'rel-003',
|
||||
appName: '支付网关',
|
||||
version: 'v3.0.2',
|
||||
environment: 'staging',
|
||||
status: 'deploying',
|
||||
commitId: 'i7j8k9l',
|
||||
branch: 'develop',
|
||||
applicant: '赵六',
|
||||
approver: '李四',
|
||||
description: '集成新的支付渠道',
|
||||
createdAt: '2025-04-14 10:00:00',
|
||||
deployedAt: '2025-04-14 11:15:00',
|
||||
},
|
||||
{
|
||||
id: 'rel-004',
|
||||
appName: '消息推送服务',
|
||||
version: 'v1.2.0',
|
||||
environment: 'production',
|
||||
status: 'failed',
|
||||
commitId: 'm0n1o2p',
|
||||
branch: 'main',
|
||||
applicant: '张三',
|
||||
approver: '李四',
|
||||
description: '优化推送性能',
|
||||
createdAt: '2025-04-14 08:00:00',
|
||||
deployedAt: '2025-04-14 08:30:00',
|
||||
},
|
||||
{
|
||||
id: 'rel-005',
|
||||
appName: '数据分析平台',
|
||||
version: 'v2.0.0',
|
||||
environment: 'production',
|
||||
status: 'rolled_back',
|
||||
commitId: 'q3r4s5t',
|
||||
branch: 'main',
|
||||
applicant: '王五',
|
||||
approver: '李四',
|
||||
description: '新版数据分析引擎',
|
||||
createdAt: '2025-04-13 14:00:00',
|
||||
deployedAt: '2025-04-13 15:00:00',
|
||||
rollbackVersion: 'v1.9.8',
|
||||
},
|
||||
];
|
||||
import { releaseApi } from '@/services/api';
|
||||
|
||||
const applications = [
|
||||
{ id: 'app-1', name: '用户中心服务', currentVersion: 'v2.3.0' },
|
||||
@@ -156,7 +68,9 @@ export function ReleaseManager() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [envFilter, setEnvFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [selectedRelease, setSelectedRelease] = useState<Release | null>(null);
|
||||
const [releases, setReleases] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
|
||||
const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false);
|
||||
@@ -169,16 +83,32 @@ export function ReleaseManager() {
|
||||
description: '',
|
||||
});
|
||||
|
||||
const filteredReleases = mockReleases.filter((release) => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await releaseApi.getReleases();
|
||||
setReleases(data.list || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load releases:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredReleases = releases.filter((release) => {
|
||||
const matchesSearch =
|
||||
release.appName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
release.version.includes(searchQuery);
|
||||
(release.app_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(release.version || '').includes(searchQuery);
|
||||
const matchesEnv = envFilter === 'all' || release.environment === envFilter;
|
||||
const matchesStatus = statusFilter === 'all' || release.status === statusFilter;
|
||||
return matchesSearch && matchesEnv && matchesStatus;
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: Release['status']) => {
|
||||
const getStatusBadge = (status: any) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
@@ -222,34 +152,78 @@ export function ReleaseManager() {
|
||||
已回滚
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getEnvironmentBadge = (env: Release['environment']) => {
|
||||
const colors = {
|
||||
const getEnvironmentBadge = (env: any) => {
|
||||
const colors: Record<string, string> = {
|
||||
production: 'border-red-500/30 text-red-400 bg-red-500/10',
|
||||
staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
|
||||
development: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
|
||||
};
|
||||
const labels = {
|
||||
const labels: Record<string, string> = {
|
||||
production: '生产环境',
|
||||
staging: '预发环境',
|
||||
development: '开发环境',
|
||||
};
|
||||
return (
|
||||
<Badge variant="outline" className={colors[env]}>
|
||||
{labels[env]}
|
||||
<Badge variant="outline" className={colors[env] || colors.development}>
|
||||
{labels[env] || env}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateRelease = () => {
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewRelease({ appId: '', version: '', environment: 'staging', branch: '', commitId: '', description: '' });
|
||||
const handleCreateRelease = async () => {
|
||||
try {
|
||||
await releaseApi.createRelease({
|
||||
app_name: applications.find((a) => a.id === newRelease.appId)?.name || newRelease.appId,
|
||||
version: newRelease.version,
|
||||
environment: newRelease.environment,
|
||||
branch: newRelease.branch,
|
||||
commit_id: newRelease.commitId,
|
||||
description: newRelease.description,
|
||||
});
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewRelease({ appId: '', version: '', environment: 'staging', branch: '', commitId: '', description: '' });
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create release:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = (approved: boolean) => {
|
||||
setIsApproveDialogOpen(false);
|
||||
const handleApprove = async (approved: boolean) => {
|
||||
if (!selectedRelease) return;
|
||||
try {
|
||||
if (approved) {
|
||||
await releaseApi.approveRelease(selectedRelease.id);
|
||||
} else {
|
||||
await releaseApi.rollbackRelease(selectedRelease.id);
|
||||
}
|
||||
setIsApproveDialogOpen(false);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to process approval:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async (id: any) => {
|
||||
try {
|
||||
await releaseApi.deployRelease(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy release:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRollback = async (id: any) => {
|
||||
try {
|
||||
await releaseApi.rollbackRelease(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to rollback release:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -272,7 +246,7 @@ export function ReleaseManager() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">待审批</p>
|
||||
<p className="text-2xl font-bold text-amber-400">
|
||||
{mockReleases.filter((r) => r.status === 'pending').length}
|
||||
{releases.filter((r) => r.status === 'pending').length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -280,7 +254,7 @@ export function ReleaseManager() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">发布中</p>
|
||||
<p className="text-2xl font-bold text-blue-400">
|
||||
{mockReleases.filter((r) => r.status === 'deploying').length}
|
||||
{releases.filter((r) => r.status === 'deploying').length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -288,7 +262,7 @@ export function ReleaseManager() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">今日成功</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{mockReleases.filter((r) => r.status === 'success').length}
|
||||
{releases.filter((r) => r.status === 'success').length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -296,7 +270,7 @@ export function ReleaseManager() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">今日失败</p>
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{mockReleases.filter((r) => r.status === 'failed').length}
|
||||
{releases.filter((r) => r.status === 'failed').length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -304,7 +278,7 @@ export function ReleaseManager() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">已回滚</p>
|
||||
<p className="text-2xl font-bold text-slate-400">
|
||||
{mockReleases.filter((r) => r.status === 'rolled_back').length}
|
||||
{releases.filter((r) => r.status === 'rolled_back').length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -346,6 +320,12 @@ export function ReleaseManager() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Release List */}
|
||||
<div className="space-y-3">
|
||||
{filteredReleases.map((release) => (
|
||||
@@ -354,7 +334,7 @@ export function ReleaseManager() {
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-medium text-slate-100">{release.appName}</h3>
|
||||
<h3 className="font-medium text-slate-100">{release.app_name}</h3>
|
||||
{getStatusBadge(release.status)}
|
||||
{getEnvironmentBadge(release.environment)}
|
||||
</div>
|
||||
@@ -369,19 +349,18 @@ export function ReleaseManager() {
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitCommit className="h-3 w-3" />
|
||||
{release.commitId}
|
||||
{release.commit_id}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
申请人: {release.applicant}
|
||||
发布人: {release.deployer}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-2">{release.description}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
<span>创建: {release.createdAt}</span>
|
||||
{release.deployedAt && <span>部署: {release.deployedAt}</span>}
|
||||
{release.rollbackVersion && (
|
||||
<span className="text-red-400">回滚至: {release.rollbackVersion}</span>
|
||||
<span>创建: {release.created_at ? new Date(release.created_at).toLocaleString() : '--'}</span>
|
||||
{release.updated_at && <span>部署: {new Date(release.updated_at).toLocaleString()}</span>}
|
||||
{release.rollback_to && (
|
||||
<span className="text-red-400">回滚至: {release.rollback_to}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -402,13 +381,23 @@ export function ReleaseManager() {
|
||||
</Button>
|
||||
)}
|
||||
{release.status === 'approved' && (
|
||||
<Button variant="outline" size="sm" className="border-blue-600 text-blue-400 hover:bg-blue-600/20">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-blue-600 text-blue-400 hover:bg-blue-600/20"
|
||||
onClick={() => handleDeploy(release.id)}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
开始发布
|
||||
</Button>
|
||||
)}
|
||||
{(release.status === 'success' || release.status === 'failed') && (
|
||||
<Button variant="outline" size="sm" className="border-amber-600 text-amber-400 hover:bg-amber-600/20">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-amber-600 text-amber-400 hover:bg-amber-600/20"
|
||||
onClick={() => handleRollback(release.id)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
回滚
|
||||
</Button>
|
||||
@@ -531,13 +520,13 @@ export function ReleaseManager() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>审批发布</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
{selectedRelease?.appName} - {selectedRelease?.version}
|
||||
{selectedRelease?.app_name} - {selectedRelease?.version}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="p-4 bg-slate-800 rounded-lg">
|
||||
<p className="text-sm text-slate-400">发布说明</p>
|
||||
<p className="text-slate-200 mt-1">{selectedRelease?.description}</p>
|
||||
<p className="text-slate-200 mt-1">{selectedRelease?.description || '无'}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Label className="text-slate-300">审批意见</Label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
Search,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { k8sClusters, k8sNodes, k8sPods, k8sDeployments, k8sServices, k8sIngresses } from '@/mocks/data';
|
||||
import { k8sApi } from '@/services/api';
|
||||
import type { K8sCluster } from '@/types';
|
||||
import {
|
||||
LineChart,
|
||||
@@ -61,13 +61,79 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
export function K8sClusters() {
|
||||
const [clusters, setClusters] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredClusters = k8sClusters.filter(
|
||||
// 集群详情关联资源
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
const [deployments, setDeployments] = useState<any[]>([]);
|
||||
const [services, setServices] = useState<any[]>([]);
|
||||
const [ingresses, setIngresses] = useState<any[]>([]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await k8sApi.getClusters();
|
||||
setClusters(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载集群列表失败:', e);
|
||||
setClusters([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadClusterDetail = async (clusterId: string) => {
|
||||
try {
|
||||
const [nodesRes, podsRes, depRes, svcRes, ingRes] = await Promise.all([
|
||||
k8sApi.getNodes(clusterId).catch(() => ({ list: [] })),
|
||||
k8sApi.getPods(clusterId).catch(() => ({ list: [] })),
|
||||
k8sApi.getDeployments(clusterId).catch(() => ({ list: [] })),
|
||||
k8sApi.getServices(clusterId).catch(() => ({ list: [] })),
|
||||
k8sApi.getIngresses(clusterId).catch(() => ({ list: [] })),
|
||||
]);
|
||||
setNodes((nodesRes as any)?.list || []);
|
||||
setPods((podsRes as any)?.list || []);
|
||||
setDeployments((depRes as any)?.list || []);
|
||||
setServices((svcRes as any)?.list || []);
|
||||
setIngresses((ingRes as any)?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载集群详情失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCluster) {
|
||||
loadClusterDetail(selectedCluster);
|
||||
} else {
|
||||
setNodes([]);
|
||||
setPods([]);
|
||||
setDeployments([]);
|
||||
setServices([]);
|
||||
setIngresses([]);
|
||||
}
|
||||
}, [selectedCluster]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await k8sApi.deleteCluster(id);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error('删除集群失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredClusters = clusters.filter(
|
||||
(cluster) =>
|
||||
cluster.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cluster.version.includes(searchQuery)
|
||||
(cluster.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(cluster.version || '').includes(searchQuery)
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: K8sCluster['status']) => {
|
||||
@@ -98,15 +164,7 @@ export function K8sClusters() {
|
||||
}
|
||||
};
|
||||
|
||||
const clusterDetail = selectedCluster
|
||||
? k8sClusters.find((c) => c.id === selectedCluster)
|
||||
: null;
|
||||
const clusterNodes = selectedCluster
|
||||
? k8sNodes.filter((n) => n.clusterId === selectedCluster)
|
||||
: [];
|
||||
const clusterPods = selectedCluster
|
||||
? k8sPods.filter((p) => p.clusterId === selectedCluster)
|
||||
: [];
|
||||
const clusterDetail = selectedCluster ? clusters.find((c) => c.id === selectedCluster) : null;
|
||||
|
||||
// 模拟资源使用趋势数据
|
||||
const resourceData = [
|
||||
@@ -128,8 +186,12 @@ export function K8sClusters() {
|
||||
<p className="text-sm text-slate-500 mt-1">管理Kubernetes集群资源</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">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={loadData}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
@@ -146,7 +208,7 @@ export function K8sClusters() {
|
||||
<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">{k8sClusters.length}</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{clusters.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Cloud className="h-5 w-5" />
|
||||
@@ -160,7 +222,7 @@ export function K8sClusters() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">健康集群</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{k8sClusters.filter((c) => c.status === 'healthy').length}
|
||||
{clusters.filter((c) => c.status === 'healthy').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
@@ -175,7 +237,7 @@ export function K8sClusters() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">总节点数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">
|
||||
{k8sClusters.reduce((acc, c) => acc + c.nodes, 0)}
|
||||
{clusters.reduce((acc, c) => acc + (c.node_count || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
|
||||
@@ -190,7 +252,7 @@ export function K8sClusters() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">总Pod数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">
|
||||
{k8sClusters.reduce((acc, c) => acc + c.pods, 0)}
|
||||
{clusters.reduce((acc, c) => acc + (c.pod_count || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
|
||||
@@ -216,116 +278,139 @@ export function K8sClusters() {
|
||||
|
||||
{/* Clusters Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filteredClusters.map((cluster) => (
|
||||
<Card
|
||||
key={cluster.id}
|
||||
className={cn(
|
||||
'bg-slate-900 border-slate-800 cursor-pointer transition-all',
|
||||
selectedCluster === cluster.id
|
||||
? 'border-blue-500 ring-1 ring-blue-500/30'
|
||||
: 'hover:border-slate-700'
|
||||
)}
|
||||
onClick={() => setSelectedCluster(cluster.id)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Cloud className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base text-slate-100">{cluster.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">{cluster.version}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusBadge(cluster.status)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
|
||||
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
Kubectl
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
配置
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.nodes}</div>
|
||||
<div className="text-xs text-slate-500">节点</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.pods}</div>
|
||||
<div className="text-xs text-slate-500">Pod</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.services}</div>
|
||||
<div className="text-xs text-slate-500">Service</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.deployments}</div>
|
||||
<div className="text-xs text-slate-500">Deployment</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-10">CPU</span>
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
cluster.cpuUsage > 80 ? 'bg-red-400' : cluster.cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${cluster.cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-10 text-right">{cluster.cpuUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-10">内存</span>
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
cluster.memoryUsage > 80 ? 'bg-red-400' : cluster.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${cluster.memoryUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-10 text-right">{cluster.memoryUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Card className="bg-slate-900 border-slate-800 col-span-full">
|
||||
<CardContent className="p-12 text-center text-slate-500">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
|
||||
加载中...
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
) : filteredClusters.length === 0 ? (
|
||||
<Card className="bg-slate-900 border-slate-800 col-span-full">
|
||||
<CardContent className="p-12 text-center text-slate-500">暂无集群数据</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredClusters.map((cluster) => {
|
||||
const cpuUsage = Number(cluster.cpu_usage || 0);
|
||||
const memoryUsage = Number(cluster.memory_usage || 0);
|
||||
return (
|
||||
<Card
|
||||
key={cluster.id}
|
||||
className={cn(
|
||||
'bg-slate-900 border-slate-800 cursor-pointer transition-all',
|
||||
selectedCluster === cluster.id
|
||||
? 'border-blue-500 ring-1 ring-blue-500/30'
|
||||
: 'hover:border-slate-700'
|
||||
)}
|
||||
onClick={() => setSelectedCluster(cluster.id)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Cloud className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base text-slate-100">{cluster.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">{cluster.version}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusBadge(cluster.status)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
|
||||
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
Kubectl
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
配置
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(cluster.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.node_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">节点</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.pod_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">Pod</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.service_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">Service</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.deployment_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">Deployment</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-10">CPU</span>
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
cpuUsage > 80 ? 'bg-red-400' : cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-10 text-right">{cpuUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-10">内存</span>
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
memoryUsage > 80 ? 'bg-red-400' : memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${memoryUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-10 text-right">{memoryUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cluster Detail */}
|
||||
@@ -338,8 +423,8 @@ export function K8sClusters() {
|
||||
<div>
|
||||
<CardTitle className="text-slate-100">{clusterDetail.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">
|
||||
{clusterDetail.version} · {clusterDetail.nodes} 节点 · 创建于{' '}
|
||||
{new Date(clusterDetail.createdAt).toLocaleDateString()}
|
||||
{clusterDetail.version} · {clusterDetail.node_count || 0} 节点 · 创建于{' '}
|
||||
{clusterDetail.created_at ? new Date(clusterDetail.created_at).toLocaleDateString() : '-'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,19 +446,19 @@ export function K8sClusters() {
|
||||
概览
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="nodes" className="data-[state=active]:bg-slate-700">
|
||||
节点 ({clusterNodes.length})
|
||||
节点 ({nodes.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
|
||||
Pod ({clusterPods.length})
|
||||
Pod ({pods.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700">
|
||||
Deployment ({k8sDeployments.length})
|
||||
Deployment ({deployments.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="services" className="data-[state=active]:bg-slate-700">
|
||||
Service ({k8sServices.length})
|
||||
Service ({services.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ingresses" className="data-[state=active]:bg-slate-700">
|
||||
Ingress ({k8sIngresses.length})
|
||||
Ingress ({ingresses.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
|
||||
事件
|
||||
@@ -440,19 +525,19 @@ export function K8sClusters() {
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-blue-400">{clusterDetail.pods}</div>
|
||||
<div className="text-3xl font-bold text-blue-400">{clusterDetail.pod_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Pod</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-emerald-400">{clusterDetail.services}</div>
|
||||
<div className="text-3xl font-bold text-emerald-400">{clusterDetail.service_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Service</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-purple-400">{clusterDetail.deployments}</div>
|
||||
<div className="text-3xl font-bold text-purple-400">{clusterDetail.deployment_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Deployment</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-amber-400">{clusterDetail.nodes}</div>
|
||||
<div className="text-3xl font-bold text-amber-400">{clusterDetail.node_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">节点</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -476,8 +561,13 @@ export function K8sClusters() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{clusterNodes.map((node) => (
|
||||
<tr key={node.id} className="hover:bg-slate-800/50">
|
||||
{nodes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">暂无节点数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
nodes.map((node) => (
|
||||
<tr key={node.id || node.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{node.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
@@ -504,15 +594,16 @@ export function K8sClusters() {
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
{node.cpuUsage.toFixed(1)}% / {node.cpu}核
|
||||
{Number(node.cpu_usage || 0).toFixed(1)}% / {node.cpu}核
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
{node.memoryUsage.toFixed(1)}% / {node.memory}GB
|
||||
{Number(node.memory_usage || 0).toFixed(1)}% / {node.memory}GB
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{node.pods}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{node.pods ?? node.pod_count ?? 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{node.os}</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -533,8 +624,13 @@ export function K8sClusters() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{clusterPods.map((pod) => (
|
||||
<tr key={pod.id} className="hover:bg-slate-800/50">
|
||||
{pods.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">暂无Pod数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
pods.map((pod) => (
|
||||
<tr key={pod.id || pod.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{pod.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
@@ -555,12 +651,13 @@ export function K8sClusters() {
|
||||
{pod.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.restarts}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.restarts ?? 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.cpu}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.memory}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{pod.node}</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -580,8 +677,13 @@ export function K8sClusters() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{k8sDeployments.map((deployment) => (
|
||||
<tr key={deployment.id} className="hover:bg-slate-800/50">
|
||||
{deployments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">暂无Deployment数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
deployments.map((deployment) => (
|
||||
<tr key={deployment.id || deployment.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{deployment.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
@@ -590,14 +692,15 @@ export function K8sClusters() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{deployment.replicas}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-sm ${deployment.availableReplicas === deployment.replicas ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{deployment.availableReplicas} / {deployment.replicas}
|
||||
<span className={`text-sm ${(deployment.available_replicas ?? deployment.availableReplicas) === deployment.replicas ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{deployment.available_replicas ?? deployment.availableReplicas} / {deployment.replicas}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400 truncate max-w-[200px]">{deployment.image}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{deployment.createdAt}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{deployment.created_at || deployment.createdAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -617,8 +720,13 @@ export function K8sClusters() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{k8sServices.map((service) => (
|
||||
<tr key={service.id} className="hover:bg-slate-800/50">
|
||||
{services.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">暂无Service数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
services.map((service) => (
|
||||
<tr key={service.id || service.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{service.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
@@ -639,11 +747,12 @@ export function K8sClusters() {
|
||||
{service.type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{service.clusterIP}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{service.cluster_ip ?? service.clusterIP}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{service.ports}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{service.selector}</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -662,8 +771,13 @@ export function K8sClusters() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{k8sIngresses.map((ingress) => (
|
||||
<tr key={ingress.id} className="hover:bg-slate-800/50">
|
||||
{ingresses.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">暂无Ingress数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
ingresses.map((ingress) => (
|
||||
<tr key={ingress.id || ingress.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{ingress.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
@@ -685,7 +799,8 @@ export function K8sClusters() {
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useThemeStore, useTabStore, useNotificationStore } from '@/store';
|
||||
import { useThemeStore, useTabStore, useNotificationStore, useUserStore } from '@/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -31,15 +31,27 @@ import {
|
||||
Rocket,
|
||||
Server,
|
||||
Cloud,
|
||||
Palette,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow } from '@/lib/utils';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
|
||||
export function Header() {
|
||||
const { sidebarCollapsed, toggleSidebar } = useThemeStore();
|
||||
const { tabs, activeTab, removeTab, setActiveTab } = useTabStore();
|
||||
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotificationStore();
|
||||
const { user, logout } = useUserStore();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
const displayName = user?.real_name || user?.username || '管理员';
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
@@ -101,6 +113,39 @@ export function Header() {
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme Switcher */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 bg-slate-800 border-slate-700">
|
||||
<DropdownMenuLabel className="text-slate-300">主题切换</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 cursor-pointer focus:bg-slate-700 focus:text-slate-100',
|
||||
theme === option.id ? 'text-slate-100' : 'text-slate-300'
|
||||
)}
|
||||
onClick={() => setTheme(option.id)}
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full shrink-0 ring-2 ring-slate-900"
|
||||
style={{ backgroundColor: option.primaryColor }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{option.name}</span>
|
||||
<span className="block text-xs text-slate-500 truncate">{option.description}</span>
|
||||
</div>
|
||||
{theme === option.id && <Check className="h-4 w-4 text-blue-400 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Create New */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -201,9 +246,9 @@ export function Header() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 gap-2 px-2 text-slate-300 hover:text-slate-100">
|
||||
<div className="w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-medium">
|
||||
管
|
||||
{displayName.charAt(0)}
|
||||
</div>
|
||||
<span className="hidden md:inline text-sm">管理员</span>
|
||||
<span className="hidden md:inline text-sm">{displayName}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
|
||||
@@ -218,7 +263,7 @@ export function Header() {
|
||||
账号设置
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
|
||||
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400" onClick={handleLogout}>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -34,9 +34,9 @@ import {
|
||||
MessageSquare,
|
||||
Phone,
|
||||
VolumeX,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { alertRules, alertEvents } from '@/mocks/data';
|
||||
import type { AlertEvent } from '@/types';
|
||||
import { alertApi } from '@/services/api';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
@@ -47,22 +47,59 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
function hasChannel(notifyChannels: any, channel: string): boolean {
|
||||
if (!notifyChannels) return false;
|
||||
if (Array.isArray(notifyChannels)) return notifyChannels.includes(channel);
|
||||
if (typeof notifyChannels === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(notifyChannels);
|
||||
return Array.isArray(parsed) ? parsed.includes(channel) : notifyChannels.includes(channel);
|
||||
} catch {
|
||||
return notifyChannels.includes(channel);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function AlertManager() {
|
||||
const [activeTab, setActiveTab] = useState('events');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [alerts, setAlerts] = useState<any[]>([]);
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const filteredEvents = alertEvents.filter((event) => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [alertsData, rulesData] = await Promise.all([
|
||||
alertApi.getAlerts(),
|
||||
alertApi.getRules(),
|
||||
]);
|
||||
setAlerts((alertsData as any)?.list || []);
|
||||
setRules(rulesData || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load alert data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredEvents = alerts.filter((event) => {
|
||||
const matchesSearch =
|
||||
event.ruleName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
event.target.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
((event.rule && event.rule.name) || event.message || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(event.target || '').toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesSeverity = severityFilter === 'all' || event.severity === severityFilter;
|
||||
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
|
||||
return matchesSearch && matchesSeverity && matchesStatus;
|
||||
});
|
||||
|
||||
const getSeverityBadge = (severity: AlertEvent['severity']) => {
|
||||
const getSeverityBadge = (severity: any) => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return (
|
||||
@@ -90,7 +127,7 @@ export function AlertManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: AlertEvent['status']) => {
|
||||
const getStatusBadge = (status: any) => {
|
||||
switch (status) {
|
||||
case 'firing':
|
||||
return (
|
||||
@@ -109,6 +146,24 @@ export function AlertManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcknowledge = async (id: any) => {
|
||||
try {
|
||||
await alertApi.acknowledge(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge alert:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRule = async (id: any) => {
|
||||
try {
|
||||
await alertApi.toggleRule(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle rule:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 告警趋势数据
|
||||
const alertTrendData = [
|
||||
{ time: '00:00', critical: 2, warning: 5, info: 8 },
|
||||
@@ -147,7 +202,7 @@ export function AlertManager() {
|
||||
<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">{alertRules.length}</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{rules.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Bell className="h-5 w-5" />
|
||||
@@ -161,7 +216,7 @@ export function AlertManager() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">触发中</p>
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{alertEvents.filter((e) => e.status === 'firing').length}
|
||||
{alerts.filter((e) => e.status === 'firing').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
|
||||
@@ -175,7 +230,7 @@ export function AlertManager() {
|
||||
<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">{alertEvents.length}</p>
|
||||
<p className="text-2xl font-bold text-amber-400">{alerts.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<Clock className="h-5 w-5" />
|
||||
@@ -189,7 +244,7 @@ export function AlertManager() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">已恢复</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{alertEvents.filter((e) => e.status === 'resolved').length}
|
||||
{alerts.filter((e) => e.status === 'resolved').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
@@ -318,11 +373,20 @@ export function AlertManager() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEvents.map((event) => (
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={8} className="text-center text-slate-500 py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEvents.map((event) => (
|
||||
<TableRow key={event.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>{getSeverityBadge(event.severity)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-medium text-slate-200">{event.ruleName}</span>
|
||||
<span className="text-sm font-medium text-slate-200">
|
||||
{(event.rule && event.rule.name) || event.message}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(event.status)}</TableCell>
|
||||
<TableCell>
|
||||
@@ -336,7 +400,7 @@ export function AlertManager() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{new Date(event.startedAt).toLocaleString()}
|
||||
{event.created_at ? new Date(event.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -346,6 +410,7 @@ export function AlertManager() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-slate-400 hover:text-emerald-400"
|
||||
onClick={() => handleAcknowledge(event.id)}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
确认
|
||||
@@ -380,7 +445,7 @@ export function AlertManager() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alertRules.map((rule) => (
|
||||
{rules.map((rule) => (
|
||||
<TableRow key={rule.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<div>
|
||||
@@ -414,23 +479,27 @@ export function AlertManager() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{rule.duration / 60}分钟</span>
|
||||
<span className="text-sm text-slate-300">{(rule.duration || 0) / 60}分钟</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{rule.notifyChannels.includes('email') && (
|
||||
{hasChannel(rule.notify_channels, 'email') && (
|
||||
<Mail className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
{rule.notifyChannels.includes('sms') && (
|
||||
{hasChannel(rule.notify_channels, 'sms') && (
|
||||
<Phone className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
{rule.notifyChannels.includes('dingtalk') && (
|
||||
{hasChannel(rule.notify_channels, 'dingtalk') && (
|
||||
<MessageSquare className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch checked={rule.enabled} className="data-[state=checked]:bg-blue-600" />
|
||||
<Switch
|
||||
checked={!!rule.enabled}
|
||||
className="data-[state=checked]:bg-blue-600"
|
||||
onCheckedChange={() => handleToggleRule(rule.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Search,
|
||||
ExternalLink,
|
||||
@@ -15,16 +16,16 @@ import {
|
||||
Star,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { navigationGroups, navigationLinks } from '@/mocks/data';
|
||||
import { navigationApi } from '@/services/api';
|
||||
|
||||
interface NavLink {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
groupId: string;
|
||||
isFavorite?: boolean;
|
||||
group_id: string;
|
||||
is_favorite?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ interface NavGroup {
|
||||
name: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
linkCount?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export function NavigationPage() {
|
||||
@@ -41,28 +42,66 @@ export function NavigationPage() {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [selectedGroup, setSelectedGroup] = useState<string>('all');
|
||||
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||
const [groups, setGroups] = useState<NavGroup[]>([]);
|
||||
const [links, setLinks] = useState<NavLink[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const groups = navigationGroups;
|
||||
const links = navigationLinks;
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [groupsData, linksData] = await Promise.all([
|
||||
navigationApi.getGroups(),
|
||||
navigationApi.getLinks(),
|
||||
]);
|
||||
setGroups(groupsData || []);
|
||||
setLinks(linksData || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load navigation data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = async (id: string) => {
|
||||
try {
|
||||
await navigationApi.toggleFavorite(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle favorite:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLink = async (id: string) => {
|
||||
try {
|
||||
await navigationApi.deleteLink(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete link:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLinks = links.filter(link => {
|
||||
const matchesSearch = link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
link.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
link.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
const matchesGroup = selectedGroup === 'all' || link.groupId === selectedGroup;
|
||||
const matchesFavorite = !showFavoritesOnly || link.isFavorite;
|
||||
const matchesSearch = (link.title || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(link.description || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(link.tags || []).some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
const matchesGroup = selectedGroup === 'all' || link.group_id === selectedGroup;
|
||||
const matchesFavorite = !showFavoritesOnly || link.is_favorite;
|
||||
return matchesSearch && matchesGroup && matchesFavorite;
|
||||
});
|
||||
|
||||
const groupedLinks = groups.map(group => ({
|
||||
...group,
|
||||
links: filteredLinks.filter(link => link.groupId === group.id)
|
||||
links: filteredLinks.filter(link => link.group_id === group.id)
|
||||
})).filter(group => group.links.length > 0);
|
||||
|
||||
const stats = {
|
||||
totalLinks: links.length,
|
||||
totalGroups: groups.length,
|
||||
favorites: links.filter(l => l.isFavorite).length,
|
||||
favorites: links.filter(l => l.is_favorite).length,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -178,6 +217,12 @@ export function NavigationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Navigation Content */}
|
||||
<div className="space-y-6">
|
||||
{selectedGroup === 'all' && !searchQuery && !showFavoritesOnly ? (
|
||||
@@ -194,13 +239,13 @@ export function NavigationPage() {
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{group.links.map(link => (
|
||||
<NavLinkCard key={link.id} link={link} />
|
||||
<NavLinkCard key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{group.links.map(link => (
|
||||
<NavLinkRow key={link.id} link={link} />
|
||||
<NavLinkRow key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -211,13 +256,13 @@ export function NavigationPage() {
|
||||
viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{filteredLinks.map(link => (
|
||||
<NavLinkCard key={link.id} link={link} />
|
||||
<NavLinkCard key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredLinks.map(link => (
|
||||
<NavLinkRow key={link.id} link={link} />
|
||||
<NavLinkRow key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -227,23 +272,23 @@ export function NavigationPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinkCard({ link }: { link: NavLink }) {
|
||||
function NavLinkCard({ link, onToggleFavorite, onDelete }: { link: NavLink; onToggleFavorite: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
return (
|
||||
<Card className="bg-slate-800 border-slate-700 hover:border-blue-500/50 transition-colors group">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-lg">
|
||||
{link.name.slice(0, 2)}
|
||||
{(link.title || '').slice(0, 2)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-slate-100 truncate">{link.name}</h3>
|
||||
<h3 className="font-medium text-slate-100 truncate">{link.title}</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5 line-clamp-2">{link.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400">
|
||||
<Star className={`h-4 w-4 ${link.isFavorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400" onClick={() => onToggleFavorite(link.id)}>
|
||||
<Star className={`h-4 w-4 ${link.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
@@ -252,7 +297,7 @@ function NavLinkCard({ link }: { link: NavLink }) {
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-700">
|
||||
<div className="flex gap-1">
|
||||
{link.tags?.map(tag => (
|
||||
{(link.tags || []).map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="bg-slate-700 text-slate-300 text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
@@ -270,31 +315,31 @@ function NavLinkCard({ link }: { link: NavLink }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinkRow({ link }: { link: NavLink }) {
|
||||
function NavLinkRow({ link, onToggleFavorite, onDelete }: { link: NavLink; onToggleFavorite: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
return (
|
||||
<Card className="bg-slate-800 border-slate-700 hover:border-blue-500/50 transition-colors">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-sm">
|
||||
{link.name.slice(0, 2)}
|
||||
{(link.title || '').slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-100">{link.name}</h3>
|
||||
<h3 className="font-medium text-slate-100">{link.title}</h3>
|
||||
<p className="text-xs text-slate-400">{link.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-1">
|
||||
{link.tags?.map(tag => (
|
||||
{(link.tags || []).map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="bg-slate-700 text-slate-300 text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400">
|
||||
<Star className={`h-4 w-4 ${link.isFavorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400" onClick={() => onToggleFavorite(link.id)}>
|
||||
<Star className={`h-4 w-4 ${link.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
RefreshCw,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { cloudAccounts } from '@/mocks/data';
|
||||
import { cloudApi } from '@/services/api';
|
||||
|
||||
interface CloudServerImportProps {
|
||||
open: boolean;
|
||||
@@ -45,12 +45,26 @@ const mockCloudServers = [
|
||||
];
|
||||
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const res: any = await cloudApi.getAccounts();
|
||||
setCloudAccounts(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载云账号失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
const filteredAccounts = cloudAccounts.filter(
|
||||
acc => acc.provider === selectedProvider && acc.status === 'active'
|
||||
);
|
||||
@@ -196,14 +210,14 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
||||
<Key className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="font-medium text-slate-200">{account.name}</p>
|
||||
<p className="text-xs text-slate-500">{account.accessKey.slice(0, 20)}...</p>
|
||||
<p className="text-xs text-slate-500">{(account.access_key || account.accessKey || '').slice(0, 20)}...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-0">
|
||||
{account.regionCount} 区域
|
||||
{account.region_count ?? account.regionCount ?? 0} 区域
|
||||
</Badge>
|
||||
<p className="text-xs text-slate-500 mt-1">{account.serverCount} 服务器</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{account.server_count ?? account.serverCount ?? 0} 服务器</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -58,13 +58,16 @@ import {
|
||||
Square,
|
||||
Cloud,
|
||||
FileSpreadsheet,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { servers } from '@/mocks/data';
|
||||
import { serverApi } from '@/services/api';
|
||||
import type { Server as ServerType } from '@/types';
|
||||
import { CloudServerImport } from './CloudServerImport';
|
||||
import { ExcelServerImport } from './ExcelServerImport';
|
||||
|
||||
export function ServerManager() {
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
@@ -72,12 +75,53 @@ export function ServerManager() {
|
||||
const [cloudImportOpen, setCloudImportOpen] = useState(false);
|
||||
const [excelImportOpen, setExcelImportOpen] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await serverApi.getServers();
|
||||
setServers(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载服务器列表失败:', e);
|
||||
setServers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// 解析标签:后端 tags 可能是 JSON 字符串、逗号分隔字符串或数组
|
||||
const getTags = (server: any): string[] => {
|
||||
const tags = server.tags;
|
||||
if (!tags) return [];
|
||||
if (Array.isArray(tags)) return tags;
|
||||
try {
|
||||
const parsed = JSON.parse(tags);
|
||||
return Array.isArray(parsed) ? parsed : [String(tags)];
|
||||
} catch {
|
||||
return String(tags)
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取区域名称:后端 region 可能是对象 {name} 或字符串
|
||||
const getRegionName = (server: any): string => {
|
||||
if (!server.region) return '';
|
||||
if (typeof server.region === 'string') return server.region;
|
||||
return server.region.name || '';
|
||||
};
|
||||
|
||||
const filteredServers = servers.filter((server) => {
|
||||
const region = getRegionName(server);
|
||||
const matchesSearch =
|
||||
server.hostname.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
server.ip.includes(searchQuery);
|
||||
(server.hostname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(server.ip || '').includes(searchQuery);
|
||||
const matchesStatus = statusFilter === 'all' || server.status === statusFilter;
|
||||
const matchesRegion = regionFilter === 'all' || server.region === regionFilter;
|
||||
const matchesRegion = regionFilter === 'all' || region === regionFilter;
|
||||
return matchesSearch && matchesStatus && matchesRegion;
|
||||
});
|
||||
|
||||
@@ -97,6 +141,15 @@ export function ServerManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await serverApi.deleteServer(id);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error('删除服务器失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ServerType['status']) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
@@ -130,7 +183,7 @@ export function ServerManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const regions = Array.from(new Set(servers.map((s) => s.region)));
|
||||
const regions = Array.from(new Set(servers.map((s) => getRegionName(s)).filter(Boolean)));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -141,6 +194,14 @@ export function ServerManager() {
|
||||
<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"
|
||||
onClick={loadData}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
@@ -155,7 +216,7 @@ export function ServerManager() {
|
||||
<DropdownMenuContent className="bg-slate-800 border-slate-700 text-slate-100">
|
||||
<DropdownMenuLabel>选择导入方式</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuItem
|
||||
className="hover:bg-slate-700 cursor-pointer"
|
||||
onClick={() => setCloudImportOpen(true)}
|
||||
>
|
||||
@@ -163,7 +224,7 @@ export function ServerManager() {
|
||||
云主机导入
|
||||
<span className="ml-2 text-xs text-slate-500">从云平台同步</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuItem
|
||||
className="hover:bg-slate-700 cursor-pointer"
|
||||
onClick={() => setExcelImportOpen(true)}
|
||||
>
|
||||
@@ -322,131 +383,155 @@ export function ServerManager() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredServers.map((server) => (
|
||||
<TableRow key={server.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedServers.includes(server.id)}
|
||||
onCheckedChange={() => toggleSelect(server.id)}
|
||||
className="border-slate-600"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-slate-500" />
|
||||
<div>
|
||||
<p className="font-medium text-slate-200">{server.hostname}</p>
|
||||
<p className="text-xs text-slate-500">{server.os}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
|
||||
{server.ip}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(server.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-slate-300">
|
||||
<span className="text-slate-500">{server.cpu}核</span>
|
||||
<span className="mx-1 text-slate-600">/</span>
|
||||
<span className="text-slate-500">{server.memory}GB</span>
|
||||
<span className="mx-1 text-slate-600">/</span>
|
||||
<span className="text-slate-500">{server.disk}GB</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-8">CPU</span>
|
||||
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
server.cpuUsage > 80 ? 'bg-red-400' : server.cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${server.cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{server.cpuUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-8">内存</span>
|
||||
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
server.memoryUsage > 80 ? 'bg-red-400' : server.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${server.memoryUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{server.memoryUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{server.region}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{server.tags.slice(0, 3).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs border-slate-700 text-slate-400 bg-slate-800/50"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{server.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
|
||||
+{server.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="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-blue-400">
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
|
||||
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
WebShell
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
重启
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={9} className="text-center text-slate-500 py-12">
|
||||
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
) : filteredServers.length === 0 ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={9} className="text-center text-slate-500 py-12">
|
||||
暂无服务器数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredServers.map((server) => {
|
||||
const cpuUsage = Number(server.cpu_usage || 0);
|
||||
const memoryUsage = Number(server.memory_usage || 0);
|
||||
const tags = getTags(server);
|
||||
const region = getRegionName(server);
|
||||
return (
|
||||
<TableRow key={server.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedServers.includes(server.id)}
|
||||
onCheckedChange={() => toggleSelect(server.id)}
|
||||
className="border-slate-600"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-slate-500" />
|
||||
<div>
|
||||
<p className="font-medium text-slate-200">{server.hostname}</p>
|
||||
<p className="text-xs text-slate-500">{server.os}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
|
||||
{server.ip}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(server.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-slate-300">
|
||||
<span className="text-slate-500">{server.cpu}核</span>
|
||||
<span className="mx-1 text-slate-600">/</span>
|
||||
<span className="text-slate-500">{server.memory}GB</span>
|
||||
<span className="mx-1 text-slate-600">/</span>
|
||||
<span className="text-slate-500">{server.disk}GB</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-8">CPU</span>
|
||||
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
cpuUsage > 80 ? 'bg-red-400' : cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{cpuUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-8">内存</span>
|
||||
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
memoryUsage > 80 ? 'bg-red-400' : memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${memoryUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{memoryUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{region || '-'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs border-slate-700 text-slate-400 bg-slate-800/50"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
|
||||
+{tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="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-blue-400">
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
|
||||
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
WebShell
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
重启
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
|
||||
onClick={() => handleDelete(server.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -454,13 +539,19 @@ export function ServerManager() {
|
||||
</Card>
|
||||
|
||||
{/* Import Dialogs */}
|
||||
<CloudServerImport
|
||||
open={cloudImportOpen}
|
||||
onClose={() => setCloudImportOpen(false)}
|
||||
<CloudServerImport
|
||||
open={cloudImportOpen}
|
||||
onClose={() => {
|
||||
setCloudImportOpen(false);
|
||||
loadData();
|
||||
}}
|
||||
/>
|
||||
<ExcelServerImport
|
||||
open={excelImportOpen}
|
||||
onClose={() => setExcelImportOpen(false)}
|
||||
<ExcelServerImport
|
||||
open={excelImportOpen}
|
||||
onClose={() => {
|
||||
setExcelImportOpen(false);
|
||||
loadData();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { servers } from '@/mocks/data';
|
||||
import { serverApi } from '@/services/api';
|
||||
|
||||
interface TerminalSession {
|
||||
id: string;
|
||||
@@ -50,6 +50,7 @@ interface TerminalSession {
|
||||
}
|
||||
|
||||
export function WebShell() {
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<string | null>(null);
|
||||
const [selectedServer, setSelectedServer] = useState<string>('');
|
||||
@@ -57,6 +58,20 @@ export function WebShell() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadServers = async () => {
|
||||
try {
|
||||
const res: any = await serverApi.getServers();
|
||||
setServers(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载服务器列表失败:', e);
|
||||
setServers([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadServers();
|
||||
}, []);
|
||||
|
||||
const runningServers = servers.filter((s) => s.status === 'running');
|
||||
|
||||
const createSession = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -51,20 +51,68 @@ import {
|
||||
LogOut,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { users, roles, operationLogs } from '@/mocks/data';
|
||||
import type { User as UserType, Role, OperationLog } from '@/types';
|
||||
import { roles, operationLogs } from '@/mocks/data';
|
||||
import { userApi } from '@/services/api';
|
||||
import type { Role, OperationLog } from '@/types';
|
||||
|
||||
// 后端用户数据转换为前端兼容格式
|
||||
function mapUser(u: any) {
|
||||
return {
|
||||
...u,
|
||||
status: u.status === 1 || u.status === 'active' ? ('active' as const) : ('inactive' as const),
|
||||
roles: Array.isArray(u.roles) ? u.roles : u.role ? [u.role] : [],
|
||||
lastLoginAt: u.last_login ?? u.lastLoginAt,
|
||||
createdAt: u.created_at ?? u.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
username: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: '',
|
||||
password: '',
|
||||
status: 1 as number,
|
||||
};
|
||||
|
||||
export function SystemManager() {
|
||||
const [activeTab, setActiveTab] = useState('users');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedUser, setSelectedUser] = useState<any | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||
|
||||
// 创建/编辑用户弹窗
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<any | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({ ...emptyForm });
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await userApi.getUsers({ page: 1, page_size: 100 });
|
||||
const rawUsers = res?.list ?? [];
|
||||
setUsers(rawUsers.map(mapUser));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
setUsers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(u) =>
|
||||
u.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
u.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
(u.username || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(u.nickname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(u.email || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredLogs = operationLogs.filter(
|
||||
@@ -74,7 +122,7 @@ export function SystemManager() {
|
||||
log.action.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getUserStatusBadge = (status: UserType['status']) => {
|
||||
const getUserStatusBadge = (status: 'active' | 'inactive') => {
|
||||
return status === 'active' ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
@@ -100,6 +148,64 @@ export function SystemManager() {
|
||||
);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setForm({ ...emptyForm });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (user: any) => {
|
||||
setEditingUser(user);
|
||||
setForm({
|
||||
username: user.username || '',
|
||||
nickname: user.nickname || '',
|
||||
email: user.email || '',
|
||||
phone: user.phone || '',
|
||||
role: Array.isArray(user.roles) ? user.roles[0] || '' : user.role || '',
|
||||
password: '',
|
||||
status: user.status === 'active' ? 1 : 0,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
username: form.username,
|
||||
nickname: form.nickname,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
role: form.role,
|
||||
status: form.status,
|
||||
};
|
||||
if (form.password) {
|
||||
payload.password = form.password;
|
||||
}
|
||||
if (editingUser) {
|
||||
await userApi.updateUser(editingUser.id, payload);
|
||||
} else {
|
||||
await userApi.createUser(payload);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to save user:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (user: any) => {
|
||||
if (!window.confirm(`确认删除用户「${user.nickname || user.username}」吗?`)) return;
|
||||
try {
|
||||
await userApi.deleteUser(user.id);
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
@@ -143,7 +249,7 @@ export function SystemManager() {
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建用户
|
||||
</Button>
|
||||
@@ -165,68 +271,94 @@ export function SystemManager() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
{user.nickname.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{user.nickname}</p>
|
||||
<p className="text-xs text-slate-500">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-sm text-slate-400">
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
{user.email}
|
||||
</div>
|
||||
{user.phone && (
|
||||
<div className="flex items-center gap-1 text-sm text-slate-400">
|
||||
<Phone className="h-3.5 w-3.5" />
|
||||
{user.phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role) => (
|
||||
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getUserStatusBadge(user.status)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="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"
|
||||
onClick={() => setSelectedUser(user)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="h-6 w-6 border-2 border-slate-700 border-t-blue-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={6} className="text-center py-12 text-sm text-slate-500">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
{(user.nickname || user.username || '?').charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{user.nickname || user.username}</p>
|
||||
<p className="text-xs text-slate-500">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-sm text-slate-400">
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
{user.email || '--'}
|
||||
</div>
|
||||
{user.phone && (
|
||||
<div className="flex items-center gap-1 text-sm text-slate-400">
|
||||
<Phone className="h-3.5 w-3.5" />
|
||||
{user.phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(user.roles || []).map((role: string) => (
|
||||
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getUserStatusBadge(user.status)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="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"
|
||||
onClick={() => setSelectedUser(user)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={() => openEdit(user)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
onClick={() => handleDelete(user)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -536,7 +668,7 @@ export function SystemManager() {
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">创建时间</p>
|
||||
<p className="text-sm text-slate-200">
|
||||
{selectedUser && new Date(selectedUser.createdAt).toLocaleDateString()}
|
||||
{selectedUser && selectedUser.createdAt ? new Date(selectedUser.createdAt).toLocaleDateString() : '--'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,7 +676,7 @@ export function SystemManager() {
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">角色</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedUser?.roles.map((role) => (
|
||||
{selectedUser?.roles.map((role: string) => (
|
||||
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
|
||||
{role}
|
||||
</Badge>
|
||||
@@ -558,13 +690,129 @@ export function SystemManager() {
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
重置密码
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={() => {
|
||||
if (selectedUser) {
|
||||
setSelectedUser(null);
|
||||
openEdit(selectedUser);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create / Edit User Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-blue-400" />
|
||||
{editingUser ? '编辑用户' : '新建用户'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
{editingUser ? '修改用户信息' : '创建新的系统用户'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">用户名</label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
disabled={!!editingUser}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
placeholder="登录用户名"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">昵称</label>
|
||||
<Input
|
||||
value={form.nickname}
|
||||
onChange={(e) => setForm({ ...form, nickname: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
placeholder="显示昵称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">邮箱</label>
|
||||
<Input
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">手机号</label>
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">角色</label>
|
||||
<Select value={form.role} onValueChange={(v) => setForm({ ...form, role: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="选择角色" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.name}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">状态</label>
|
||||
<Select
|
||||
value={String(form.status)}
|
||||
onValueChange={(v) => setForm({ ...form, status: Number(v) })}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="1">正常</SelectItem>
|
||||
<SelectItem value="0">禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">
|
||||
密码{editingUser ? '(留空则不修改)' : ''}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
placeholder={editingUser ? '留空则不修改' : '设置初始密码'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user