Files
aiops/frontend/src/components/deploy/pipeline-manager/DeployTargetFields.tsx
T
attacker 55f216cfbb feat(i18n): 安全批量转换 135 文件 — 覆盖率 34%→62%
使用 i18n.t() 全局方式(避免 hook 定位问题):
- 288 目标文件中 135 个成功转换
- 2228 处中文替换为 i18n.t() 调用
- 1006 个新翻译 key 添加到 zh-CN.json + en-US.json
- 词典总量: 3554 keys (22 namespaces)
- tsc 零错误 + Vite 构建成功
- 6 个含代码示例/数据字符串的文件安全回退
2026-07-03 02:14:20 +08:00

374 lines
15 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, Server, Network, Loader2, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { serverApi, kubernetesApi } from '@/api';
import { getPublicApiBase } from '@/utils/request';
import i18n from '@/i18n';
export interface DeployTargetValue {
deployTarget: 'ssh' | 'k8s';
serverIds: number[];
deployRemotePath: string;
deployScript: string;
k8sClusterId: number | '';
k8sNamespace: string;
k8sDeployment: string;
k8sContainerName: string;
}
interface DeployTargetFieldsProps {
value: DeployTargetValue;
onChange: (patch: Partial<DeployTargetValue>) => void;
requireTarget?: boolean;
}
interface ServerItem {
id: number;
hostname: string;
ip?: string;
status?: string;
}
interface ClusterItem {
id: number;
name: string;
code: string;
}
interface DeploymentItem {
namespace: string;
name: string;
image?: string;
ready_replicas?: number;
replicas?: number;
}
// 发布目标选择:SSH 关联主机 / K8s 关联 Deployment // Logan.Li
export function DeployTargetFields({ value, onChange, requireTarget = true }: DeployTargetFieldsProps) {
const [servers, setServers] = useState<ServerItem[]>([]);
const [clusters, setClusters] = useState<ClusterItem[]>([]);
const [deployments, setDeployments] = useState<DeploymentItem[]>([]);
const [serverSearch, setServerSearch] = useState('');
const [depSearch, setDepSearch] = useState('');
const [loadingServers, setLoadingServers] = useState(false);
const [loadingDeps, setLoadingDeps] = useState(false);
const loadServers = useCallback(async () => {
setLoadingServers(true);
try {
const res = await serverApi.getServers({ page_size: 200 });
const list = (res?.list ?? []) as ServerItem[];
setServers(list);
} catch {
setServers([]);
} finally {
setLoadingServers(false);
}
}, []);
const loadClusters = useCallback(async () => {
try {
const res = await kubernetesApi.getClusters();
setClusters((res?.list ?? []) as ClusterItem[]);
} catch {
setClusters([]);
}
}, []);
const loadDeployments = useCallback(async (clusterId: number | '') => {
if (!clusterId) {
setDeployments([]);
return;
}
const cluster = clusters.find((c) => c.id === clusterId);
if (!cluster?.code) {
setDeployments([]);
return;
}
setLoadingDeps(true);
try {
const res = await kubernetesApi.getAllDeployments(cluster.code);
const list = Array.isArray(res) ? res : [];
setDeployments(list as DeploymentItem[]);
} catch {
setDeployments([]);
} finally {
setLoadingDeps(false);
}
}, [clusters]);
useEffect(() => {
loadServers();
loadClusters();
}, [loadServers, loadClusters]);
useEffect(() => {
if (value.deployTarget === 'k8s' && value.k8sClusterId) {
loadDeployments(value.k8sClusterId);
}
}, [value.deployTarget, value.k8sClusterId, loadDeployments]);
const filteredServers = useMemo(() => {
const q = serverSearch.trim().toLowerCase();
if (!q) return servers;
return servers.filter(
(s) => s.hostname?.toLowerCase().includes(q) || s.ip?.toLowerCase().includes(q),
);
}, [servers, serverSearch]);
const filteredDeployments = useMemo(() => {
const q = depSearch.trim().toLowerCase();
if (!q) return deployments;
return deployments.filter(
(d) => d.name.toLowerCase().includes(q) || d.namespace.toLowerCase().includes(q),
);
}, [deployments, depSearch]);
const toggleServer = (id: number) => {
const next = value.serverIds.includes(id)
? value.serverIds.filter((x) => x !== id)
: [...value.serverIds, id];
onChange({ serverIds: next });
};
const selectDeployment = (dep: DeploymentItem) => {
onChange({
k8sNamespace: dep.namespace,
k8sDeployment: dep.name,
});
};
const selectedCluster = clusters.find((c) => c.id === value.k8sClusterId);
return (
<div className="space-y-3">
<div className="flex gap-1 p-1 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
{([
{ key: 'ssh' as const, label: i18n.t('pipeline.deploytargetfie.secf81967'), icon: Server },
{ key: 'k8s' as const, label: i18n.t('pipeline.deploytargetfie.s7ff062d5'), icon: Network },
]).map((item) => {
const active = value.deployTarget === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
type="button"
onClick={() => onChange({
deployTarget: item.key,
serverIds: item.key === 'ssh' ? value.serverIds : [],
deployRemotePath: item.key === 'ssh' ? value.deployRemotePath : '',
deployScript: item.key === 'ssh' ? value.deployScript : '',
k8sClusterId: item.key === 'k8s' ? value.k8sClusterId : '',
k8sNamespace: item.key === 'k8s' ? value.k8sNamespace : '',
k8sDeployment: item.key === 'k8s' ? value.k8sDeployment : '',
k8sContainerName: item.key === 'k8s' ? value.k8sContainerName : '',
})}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium transition-colors',
active
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] border border-[var(--dg-border)] shadow-sm'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]',
)}
>
<Icon className="h-3.5 w-3.5" />
{item.label}
</button>
);
})}
</div>
{value.deployTarget === 'ssh' ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">
关联主机
{requireTarget && <span className="text-[var(--dg-red)] ml-0.5">*</span>}
</Label>
{value.serverIds.length > 0 && (
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)]">
已选 {value.serverIds.length} 台
</Badge>
)}
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
<Input
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
placeholder={i18n.t('pipeline.deploytargetfie.sc7624940')}
className="pl-8 h-8 text-xs dg-input"
/>
</div>
<ScrollArea className="h-[140px] rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
{loadingServers ? (
<div className="flex items-center justify-center py-8 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />加载中
</div>
) : filteredServers.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8"> {i18n.t('pipeline.deploytargetfie.sb4240171')} </p>
) : (
<div className="p-1.5 space-y-1">
{filteredServers.map((srv) => {
const selected = value.serverIds.includes(srv.id);
return (
<button
key={srv.id}
type="button"
onClick={() => toggleServer(srv.id)}
className={cn(
'w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left text-xs transition-colors',
selected
? 'bg-[var(--dg-bg-hover)] border border-[var(--dg-border)]'
: 'hover:bg-[var(--dg-bg-hover)]/60',
)}
>
<span className={cn(
'w-4 h-4 rounded border flex items-center justify-center shrink-0',
selected ? 'bg-[var(--dg-blue)] border-[var(--dg-blue)]' : 'border-[var(--dg-border)]',
)}>
{selected && <Check className="h-2.5 w-2.5 text-white" />}
</span>
<Server className="h-3.5 w-3.5 text-[var(--dg-text-muted)] shrink-0" />
<span className="font-medium text-[var(--dg-text-primary)] truncate">{srv.hostname}</span>
{srv.ip && <span className="text-[var(--dg-text-muted)] ml-auto shrink-0">{srv.ip}</span>}
</button>
);
})}
</div>
)}
</ScrollArea>
<p className="text-[10px] text-[var(--dg-text-muted)]"> {i18n.t('pipeline.deploytargetfie.sb2b6db98')} </p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('pipeline.deploytargetfie.s5ca05a9b')} </Label>
<Input
value={value.deployRemotePath}
onChange={(e) => onChange({ deployRemotePath: e.target.value })}
placeholder="/opt/apps/my-app"
className="h-8 text-xs font-mono dg-input"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('pipeline.deploytargetfie.s65b20ba7')} </Label>
<Input
value={value.deployScript}
onChange={(e) => onChange({ deployScript: e.target.value })}
placeholder="./restart.sh"
className="h-8 text-xs font-mono dg-input"
/>
</div>
</div>
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">
Kubernetes 集群
{requireTarget && <span className="text-[var(--dg-red)] ml-0.5">*</span>}
</Label>
<Select
value={value.k8sClusterId ? String(value.k8sClusterId) : '__none'}
onValueChange={(v) => onChange({
k8sClusterId: v === '__none' ? '' : Number(v),
k8sNamespace: '',
k8sDeployment: '',
})}
>
<SelectTrigger className="h-8 text-xs dg-input">
<SelectValue placeholder={i18n.t('kubernetes.topology.selectCluster')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none"> {i18n.t('k8s.storage.pleaseSelect')} </SelectItem>
{clusters.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('pipeline.deploytargetfie.s806e4e01')} </Label>
<Input
readOnly
value={value.k8sDeployment ? `${value.k8sNamespace}/${value.k8sDeployment}` : ''}
placeholder={i18n.t('pipeline.deploytargetfie.sbb1671f3')}
className="h-8 text-xs dg-input font-mono"
/>
</div>
</div>
{selectedCluster && (
<>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
<Input
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
placeholder={i18n.t('pipeline.deploytargetfie.saa98b28a')}
className="pl-8 h-8 text-xs dg-input"
/>
</div>
<ScrollArea className="h-[140px] rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
{loadingDeps ? (
<div className="flex items-center justify-center py-8 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />加载 Deployment
</div>
) : filteredDeployments.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8"> {i18n.t('k8s.workloads.noDeployment')} </p>
) : (
<div className="p-1.5 space-y-1">
{filteredDeployments.map((dep) => {
const selected = value.k8sDeployment === dep.name && value.k8sNamespace === dep.namespace;
return (
<button
key={`${dep.namespace}/${dep.name}`}
type="button"
onClick={() => selectDeployment(dep)}
className={cn(
'w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left text-xs transition-colors',
selected
? 'bg-[var(--dg-bg-hover)] border border-[var(--dg-border)]'
: 'hover:bg-[var(--dg-bg-hover)]/60',
)}
>
<Network className="h-3.5 w-3.5 text-[var(--dg-text-muted)] shrink-0" />
<span className="font-mono text-[var(--dg-text-primary)] truncate">
{dep.namespace}/{dep.name}
</span>
<span className="text-[var(--dg-text-muted)] ml-auto shrink-0">
{dep.ready_replicas ?? 0}/{dep.replicas ?? 0}
</span>
</button>
);
})}
</div>
)}
</ScrollArea>
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('kubernetes.container.containerName')} <span className="text-[var(--dg-text-muted)]">(多容器时填写)</span></Label>
<Input
value={value.k8sContainerName}
onChange={(e) => onChange({ k8sContainerName: e.target.value })}
placeholder={i18n.t('pipeline.deploytargetfie.s389076f8')}
className="h-8 text-xs font-mono dg-input"
/>
</div>
{value.k8sDeployment && selectedCluster && (
<p className="text-[10px] text-[var(--dg-text-muted)] leading-relaxed">
流水线部署将调用 CI 接口:
<code className="text-[var(--dg-text-secondary)] break-all">
POST {getPublicApiBase()}/kubernetes/{selectedCluster.code}/namespace/{value.k8sNamespace}/deployments/{value.k8sDeployment}/update-image
</code>
</p>
)}
</>
)}
</div>
)}
</div>
);
}