feat: 工具配置增强、站点拨测与日志中心多项改进
- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖 - 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败 - Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS - 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率 - 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示 - 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
This commit is contained in:
1 parent
cde7bbe540
commit
7e1c8f583c
84 files changed
+7729
-1507
No files matched your search
@@ -0,0 +1,373 @@
|
||||
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';
|
||||
|
||||
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: '主机发布', icon: Server },
|
||||
{ key: 'k8s' as const, label: 'Kubernetes 发布', 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="搜索主机名或 IP"
|
||||
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">暂无可用主机</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)]">构建完成后 SCP 上传 jar/tar.gz 到远程目录并执行部署命令</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">远程目录</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">部署命令</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="选择集群" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">请选择</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">已选 Deployment</Label>
|
||||
<Input
|
||||
readOnly
|
||||
value={value.k8sDeployment ? `${value.k8sNamespace}/${value.k8sDeployment}` : ''}
|
||||
placeholder="从下方列表选择"
|
||||
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="搜索 Deployment 或命名空间"
|
||||
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">暂无 Deployment</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">容器名 <span className="text-[var(--dg-text-muted)]">(多容器时填写)</span></Label>
|
||||
<Input
|
||||
value={value.k8sContainerName}
|
||||
onChange={(e) => onChange({ k8sContainerName: e.target.value })}
|
||||
placeholder="默认首个容器"
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user