Files
aiops/frontend/src/components/server/CloudServerImport.tsx
T
attacker 5364d93ed7 fix(q1): 修复 tsc -b 构建中断 — 回退不安全的 unknown→any + 移除 Ansible 残留引用
- 781处 tsc -b 错误清零(unknown 属性访问/隐式any返回类型/类型转换不匹配)
- 移除 App.tsx 中已删除的 AnsibleManager/AnsibleWorkflow 导入和路由
- 修复 DeploymentView 中 _prefix 解构导致的属性不存在错误
- 修复 Header/Sidebar/PipelineEditor 中无效的类型断言
- asList() 返回类型 unknown[]→any[], 恢复 AgentMonitor 正常工作
- 放宽 noImplicitAny(临时), 待 types/ 定义完善后逐步恢复
- ESLint: unused=0, console=0, any=1041(Q1 后续降低)
- npm run build: ✅ 0 errors
2026-06-25 07:17:47 +08:00

664 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import {
Cloud,
Key,
CheckCircle,
ChevronRight,
ChevronLeft,
Server,
RefreshCw,
Check,
MapPin,
Search,
AlertCircle,
} from 'lucide-react';
import { cloudApi } from '@/services/api';
interface CloudServerImportProps {
open: boolean;
onClose: () => void;
}
const cloudProviders = [
{ id: 'aws', name: 'AWS', color: 'bg-orange-500', icon: 'AWS' },
{ id: 'aliyun', name: '阿里云', color: 'bg-orange-600', icon: 'AL' },
{ id: 'tencent', name: '腾讯云', color: 'bg-blue-500', icon: 'TX' },
{ id: 'huawei', name: '华为云', color: 'bg-red-500', icon: 'HW' },
{ id: 'azure', name: 'Azure', color: 'bg-blue-600', icon: 'AZ' },
];
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<any>(null);
// 区域相关状态
const [regions, setRegions] = useState<any[]>([]);
const [selectedRegions, setSelectedRegions] = useState<string[]>([]);
const [regionSearch, setRegionSearch] = useState('');
const [isSyncingRegions, setIsSyncingRegions] = useState(false);
// 主机相关状态
const [cloudServers, setCloudServers] = useState<any[]>([]);
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
const [discoverErrors, setDiscoverErrors] = useState<string[]>([]);
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState<{ imported: number; skipped: number } | null>(null);
const [defaultSshPort, setDefaultSshPort] = useState('22');
const [credentials, setCredentials] = useState<any[]>([]);
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('');
const loadAccounts = async () => {
try {
const res: Record<string, any> = await cloudApi.getAccounts();
setCloudAccounts(res?.list || []);
} catch (e) {
console.error('加载云账号失败:', e);
}
};
useEffect(() => {
if (open) {
loadAccounts();
import('@/services/api').then(({ credentialApi }) => {
credentialApi.list().then(( res: any) => setCredentials(res?.list || []))
.catch(() => setCredentials([]));
});
}
}, [open]);
const filteredAccounts = cloudAccounts.filter(
acc => acc.provider === selectedProvider && acc.status === 'active'
);
// ==================== 区域同步逻辑 ====================
const loadRegions = useCallback(async (accountId: string | number) => {
try {
const res: Record<string, any> = await cloudApi.getAccountRegions(accountId);
return res?.list || [];
} catch {
return [];
}
}, []);
const syncRegions = useCallback(async (accountId: string | number) => {
setIsSyncingRegions(true);
try {
await cloudApi.syncRegions(accountId);
const list = await loadRegions(accountId);
setRegions(list);
} catch (e: any) {
console.error('同步区域失败:', e);
throw e;
} finally {
setIsSyncingRegions(false);
}
}, [loadRegions]);
// 进入 Step 3 时自动加载/同步区域
const enterRegionStep = useCallback(async () => {
if (!selectedAccount) return;
// 先尝试加载本地已有的区域
const local = await loadRegions(selectedAccount.id);
setRegions(local);
// 如果本地没有区域数据,自动触发同步
if (local.length === 0) {
try {
await syncRegions(selectedAccount.id);
} catch {
// 同步失败不阻塞用户,可以手动重试
}
}
}, [selectedAccount, loadRegions, syncRegions]);
// ==================== 主机发现逻辑 ====================
const discoverInstances = useCallback(async () => {
if (!selectedAccount || selectedRegions.length === 0) return;
setIsDiscovering(true);
setDiscoverErrors([]);
setCloudServers([]);
setSelectedServers([]);
try {
const res: Record<string, any> = await cloudApi.discoverInstances(selectedAccount.id, selectedRegions);
setCloudServers(res?.list || []);
setDiscoverErrors(res?.errors || []);
} catch (e: any) {
console.error('发现主机失败:', e);
setDiscoverErrors([e?.message || '发现主机失败']);
} finally {
setIsDiscovering(false);
}
}, [selectedAccount, selectedRegions]);
// ==================== 导入逻辑 ====================
const handleImport = async () => {
if (!selectedAccount || selectedServers.length === 0) return;
setIsImporting(true);
try {
const instancesToImport = cloudServers.filter(s => selectedServers.includes(s.instance_id));
const res: Record<string, any> = await cloudApi.importInstances(selectedAccount.id, instancesToImport, {
default_port: Number(defaultSshPort) || 22,
credential_id: selectedCredentialId ? Number(selectedCredentialId) : undefined,
});
setImportResult((res as any) || { imported: 0, skipped: 0 });
} catch (e: any) {
console.error('导入失败:', e);
} finally {
setIsImporting(false);
}
};
// ==================== 步骤导航 ====================
const handleNext = () => {
if (step === 2 && selectedAccount && step < 4) {
setStep(3);
enterRegionStep();
} else if (step < 4) {
setStep(step + 1);
}
};
const handleBack = () => {
if (step > 1) setStep(step - 1);
};
const handleSelectAllRegions = () => {
if (selectedRegions.length === regions.length && regions.length > 0) {
setSelectedRegions([]);
} else {
setSelectedRegions(regions.map(r => r.code));
}
};
const handleServerToggle = (instanceId: string) => {
setSelectedServers(prev =>
prev.includes(instanceId)
? prev.filter(id => id !== instanceId)
: [...prev, instanceId]
);
};
const handleSelectAllServers = () => {
if (selectedServers.length === cloudServers.length && cloudServers.length > 0) {
setSelectedServers([]);
} else {
setSelectedServers(cloudServers.map(s => s.instance_id));
}
};
const handleClose = () => {
onClose();
setStep(1);
setSelectedProvider('');
setSelectedAccount(null);
setSelectedRegions([]);
setRegions([]);
setCloudServers([]);
setSelectedServers([]);
setRegionSearch('');
setDiscoverErrors([]);
setImportResult(null);
setDefaultSshPort('22');
setSelectedCredentialId('');
};
const filteredRegions = regions.filter(r =>
!regionSearch ||
r.name?.toLowerCase().includes(regionSearch.toLowerCase()) ||
r.code?.toLowerCase().includes(regionSearch.toLowerCase())
);
const steps = [
{ id: 1, title: '公有云', icon: Cloud },
{ id: 2, title: '访问凭据', icon: Key },
{ id: 3, title: '选择区域', icon: MapPin },
{ id: 4, title: '导入确认', icon: CheckCircle },
];
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle className="text-xl">导入云主机</DialogTitle>
<DialogDescription className="sr-only">从云平台批量导入主机资产</DialogDescription>
</DialogHeader>
{/* Step Indicator */}
<div className="flex items-center justify-center mb-6 flex-wrap gap-1">
{steps.map((s, index) => (
<div key={s.id} className="flex items-center">
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${
step === s.id
? 'bg-blue-600 text-white'
: step > s.id
? 'bg-emerald-600/20 text-emerald-400'
: 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]'
}`}>
<s.icon className="h-4 w-4" />
<span className="font-medium hidden sm:inline">{s.title}</span>
{step > s.id && <Check className="h-3 w-3 ml-1" />}
</div>
{index < steps.length - 1 && (
<ChevronRight className="h-4 w-4 text-[var(--dg-text-muted)] mx-1" />
)}
</div>
))}
</div>
{/* Step 1: Select Cloud Provider */}
{step === 1 && (
<div className="space-y-4">
<p className="text-[var(--dg-text-secondary)] text-sm">请选择要导入云主机的云平台</p>
<div className="grid grid-cols-3 gap-4">
{cloudProviders.map((provider) => (
<Card
key={provider.id}
className={`cursor-pointer transition-all ${
selectedProvider === provider.id
? 'border-blue-500 ring-2 ring-blue-500/30 bg-[var(--dg-bg-secondary)]'
: 'border-[var(--dg-border)] hover:border-blue-500 bg-[var(--dg-bg-secondary)]'
}`}
onClick={() => setSelectedProvider(provider.id)}
>
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${provider.color} flex items-center justify-center text-white font-bold text-sm`}>
{provider.icon}
</div>
<span className="font-medium text-[var(--dg-text-primary)]">{provider.name}</span>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Step 2: Select Account */}
{step === 2 && (
<div className="space-y-4">
<p className="text-[var(--dg-text-secondary)] text-sm">请选择访问凭据(AccessKey)</p>
{filteredAccounts.length > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{filteredAccounts.map((account) => (
<Card
key={account.id}
className={`cursor-pointer transition-all ${
selectedAccount?.id === account.id
? 'border-blue-500 ring-1 ring-blue-500/30 bg-[var(--dg-bg-secondary)]'
: 'border-[var(--dg-border)] hover:border-blue-500 bg-[var(--dg-bg-secondary)]'
}`}
onClick={() => setSelectedAccount(account)}
>
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 text-blue-400" />
<div>
<p className="font-medium text-[var(--dg-text-primary)]">{account.name}</p>
<p className="text-xs text-[var(--dg-text-muted)]">
{(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.region_count ?? account.regionCount ?? 0} 区域
</Badge>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{account.server_count ?? account.serverCount ?? 0} 服务器</p>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>该云平台暂无有效的访问凭据</p>
</div>
)}
</div>
)}
{/* Step 3: Select Regions (NEW) */}
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[var(--dg-text-secondary)] text-sm">
请选择要同步的区域
{regions.length > 0 && (
<span className="ml-2 text-[var(--dg-text-muted)]">(共 {regions.length} 个区域,已选 {selectedRegions.length} 个)</span>
)}
</p>
<Button
variant="outline"
size="sm"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={() => selectedAccount && syncRegions(selectedAccount.id)}
disabled={isSyncingRegions}
>
<RefreshCw className={`h-3 w-3 mr-1 ${isSyncingRegions ? 'animate-spin' : ''}`} />
{isSyncingRegions ? '同步中...' : '刷新区域'}
</Button>
</div>
{isSyncingRegions ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin text-blue-400" />
<p className="text-[var(--dg-text-secondary)]">正在从云平台同步区域列表...</p>
</div>
) : regions.length === 0 ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<MapPin className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p>暂无区域数据</p>
<Button
variant="outline"
size="sm"
className="mt-3 border-[var(--dg-border)] text-blue-400"
onClick={() => selectedAccount && syncRegions(selectedAccount.id)}
>
<RefreshCw className="h-3 w-3 mr-1" />
立即同步
</Button>
</div>
) : (
<>
{/* 搜索框 + 全选 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<Input
placeholder="搜索区域名称或编码..."
value={regionSearch}
onChange={(e) => setRegionSearch(e.target.value)}
className="pl-9 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
/>
</div>
<Button variant="outline" size="sm" onClick={handleSelectAllRegions} className="border-[var(--dg-border)]">
{selectedRegions.length === regions.length && regions.length > 0 ? '取消全选' : '全选'}
</Button>
</div>
{/* 区域列表 */}
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[300px] overflow-y-auto">
<table className="w-full">
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)] w-10">
<Checkbox
checked={selectedRegions.length === regions.length && regions.length > 0}
onCheckedChange={handleSelectAllRegions}
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域编码</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域名称</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">Endpoint</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{filteredRegions.map((region) => (
<tr
key={region.id || region.code}
className={`cursor-pointer hover:bg-[var(--dg-bg-hover)] ${selectedRegions.includes(region.code) ? 'bg-blue-500/10' : ''}`}
onClick={() => {
setSelectedRegions(prev =>
prev.includes(region.code)
? prev.filter(r => r !== region.code)
: [...prev, region.code]
);
}}
>
<td className="px-3 py-2">
<Checkbox checked={selectedRegions.includes(region.code)} />
</td>
<td className="px-3 py-2 text-sm text-blue-400 font-mono">{region.code}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{region.name}</td>
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)] font-mono truncate max-w-[200px]">{region.endpoint || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
{/* Step 4: Discover & Import Instances */}
{step === 4 && (
<div className="space-y-4">
{importResult ? (
/* 导入结果 */
<div className="text-center py-12">
<CheckCircle className="h-12 w-12 mx-auto mb-3 text-emerald-400" />
<p className="text-lg font-medium text-[var(--dg-text-primary)]">导入完成</p>
<div className="flex items-center justify-center gap-6 mt-4">
<Badge className="bg-emerald-500/20 text-emerald-400 border-0 text-base px-4 py-1">
成功导入 {importResult.imported} 台
</Badge>
{importResult.skipped > 0 && (
<Badge className="bg-amber-500/20 text-amber-400 border-0 text-base px-4 py-1">
跳过 {importResult.skipped} 台(已存在)
</Badge>
)}
</div>
</div>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-[var(--dg-text-secondary)] text-sm">
已选 {selectedRegions.length} 个区域
{cloudServers.length > 0 && (
<span className="ml-2">· 发现 {cloudServers.length} 台主机</span>
)}
</p>
<div className="flex items-center gap-2">
{cloudServers.length > 0 && (
<>
<Button variant="outline" size="sm" onClick={handleSelectAllServers} className="border-[var(--dg-border)]">
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? '取消全选' : '全选'}
</Button>
<Badge className="bg-blue-500/20 text-blue-400 border-0">
已选 {selectedServers.length} 台
</Badge>
</>
)}
<Button
variant="outline"
size="sm"
onClick={discoverInstances}
disabled={isDiscovering || selectedRegions.length === 0}
className="border-[var(--dg-border)] text-blue-400"
>
<RefreshCw className={`h-3 w-3 mr-1 ${isDiscovering ? 'animate-spin' : ''}`} />
{isDiscovering ? '发现中...' : cloudServers.length > 0 ? '重新发现' : '发现主机'}
</Button>
</div>
</div>
{/* 区域标签 */}
<div className="flex flex-wrap gap-1">
{selectedRegions.map(r => {
const region = regions.find(reg => reg.code === r);
return (
<Badge key={r} className="bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)] border border-[var(--dg-border)] text-xs">
<MapPin className="h-3 w-3 mr-1" />
{region?.name || r}
</Badge>
);
})}
</div>
{/* 发现错误提示 */}
{discoverErrors.length > 0 && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium">部分区域发现失败:</p>
{discoverErrors.map((err, i) => (
<p key={i} className="text-xs mt-1 opacity-80">{err}</p>
))}
</div>
</div>
)}
{/* 主机列表 */}
{isDiscovering ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin text-blue-400" />
<p className="text-[var(--dg-text-secondary)]">正在发现云主机,请稍候...</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">正在逐区域查询,可能需要数秒</p>
</div>
) : cloudServers.length === 0 && !isDiscovering ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p>点击"发现主机"按钮,开始扫描所选区域</p>
</div>
) : (
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[300px] overflow-y-auto">
<table className="w-full">
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)] w-10">
<Checkbox
checked={selectedServers.length === cloudServers.length && cloudServers.length > 0}
onCheckedChange={handleSelectAllServers}
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{cloudServers.map((server) => (
<tr
key={server.instance_id}
className={`cursor-pointer hover:bg-[var(--dg-bg-hover)] ${selectedServers.includes(server.instance_id) ? 'bg-blue-500/10' : ''}`}
onClick={() => handleServerToggle(server.instance_id)}
>
<td className="px-3 py-2">
<Checkbox checked={selectedServers.includes(server.instance_id)} />
</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{server.instance_name || server.hostname || '-'}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">
{server.private_ip || server.public_ip || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* SSH 端口 + 凭证关联 */}
{cloudServers.length > 0 && selectedServers.length > 0 && (
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">
SSH 端口
<span className="text-[var(--dg-text-muted)] ml-1">(导入时统一设置)</span>
</label>
<Input
type="number"
placeholder="22"
value={defaultSshPort}
onChange={(e) => setDefaultSshPort(e.target.value)}
className="w-32 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">
关联凭证
<span className="text-[var(--dg-text-muted)] ml-1">(可选,用于SSH远程管理)</span>
</label>
<select
value={selectedCredentialId}
onChange={(e) => setSelectedCredentialId(e.target.value)}
className="w-full px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-xs"
>
<option value="">不使用凭据</option>
{credentials.map(( c: any) => (
<option key={c.id} value={String(c.id)}>
{c.type === 'ssh_key' ? '🔑' : '🔒'} {c.name}
</option>
))}
</select>
</div>
</div>
)}
</>
)}
</div>
)}
{/* Footer */}
<DialogFooter className="flex justify-between items-center">
<div>
{step > 1 && !importResult && (
<Button variant="outline" onClick={handleBack} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
<ChevronLeft className="h-4 w-4 mr-1" />
上一步
</Button>
)}
</div>
<div className="flex gap-2">
{importResult ? (
<Button onClick={handleClose} className="bg-blue-600 hover:bg-blue-700">
完成
</Button>
) : (
<>
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
取消
</Button>
{step < 4 ? (
<Button
onClick={handleNext}
disabled={
(step === 1 && !selectedProvider) ||
(step === 2 && !selectedAccount) ||
(step === 3 && selectedRegions.length === 0)
}
className="bg-blue-600 hover:bg-blue-700"
>
下一步
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button
onClick={handleImport}
disabled={selectedServers.length === 0 || isImporting}
className="bg-emerald-600 hover:bg-emerald-700"
>
{isImporting ? (
<>
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
导入中...
</>
) : (
<>
<Check className="h-4 w-4 mr-1" />
确认导入 ({selectedServers.length})
</>
)}
</Button>
)}
</>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}