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:
Logan committed 2026-07-02 07:47:33 +08:00
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>
);
}