Files
aiops/frontend/src/components/kubernetes/KubernetesClusterDialog.tsx
T
attacker c68aef4444 feat(alert): A2 告警引擎分布式安全 — Redis 分布式锁(SetNX)
- Cache 接口新增 SetNX 方法(go-redis 原子 / redka check-then-set 单副本安全)
- AlertEngine 改造为支持分布式锁: tryAcquireLock/releaseLock/GetEngineStatus
- 多副本部署时仅 Leader 执行告警检查, 60s TTL 防死锁, fail-open 容错
- 新增 GET /alerts/engine/status 监控端点(is_leader/instance_id/lock_holder)
- 修复 GrafanaDashboard.tsx Panel.options 类型不匹配导致构建中断
- 同步未提交的前端类型安全迁移(140+ 文件)
- ROADMAP/TDD_PLAN/PROJECT_EVALUATION 三文档同步更新

验证: go build 零错误, Docker 部署 healthy, 引擎状态端点正常,
锁竞争测试通过(非 Leader 跳过检查), 5/5 核心 API 回归通过
2026-06-26 21:13:15 +08:00

271 lines
11 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { kubernetesApi } from '@/api';
import { toast } from 'sonner';
import type { JsonData } from '@/types/common';
// 名称即 code,只允许英文/数字/连字符
const NAME_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
const emptyForm = {
name: '',
version: '',
description: '',
config_type: 1, // 0=Token, 1=Kubeconfig
api_server: '',
token: '',
kube_config: '',
};
export function KubernetesClusterDialog({
open,
onOpenChange,
editingCluster,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editingCluster?: JsonData;
onSaved: () => void;
}) {
const [formData, setFormData] = useState({ ...emptyForm });
const [submitting, setSubmitting] = useState(false);
const [hasToken, setHasToken] = useState(false);
const [hasKubeConfig, setHasKubeConfig] = useState(false);
const [nameError, setNameError] = useState('');
useEffect(() => {
if (!open) return;
if (editingCluster && editingCluster.id) {
kubernetesApi.getClusterDetail(editingCluster.id).then((res: JsonData) => {
const d = res || {};
setHasToken(!!d.has_token);
setHasKubeConfig(!!d.has_kube_config);
setFormData({
name: d.name || '',
version: d.version || '',
description: d.description || '',
config_type: Number(d.config_type ?? 1),
api_server: d.api_server || '',
token: '',
kube_config: '',
});
setNameError('');
}).catch(() => {
setFormData({
name: editingCluster.name || '',
version: editingCluster.version || '',
description: editingCluster.description || '',
config_type: Number(editingCluster.config_type ?? 1),
api_server: editingCluster.api_server || '',
token: '',
kube_config: '',
});
setNameError('');
});
} else {
setHasToken(false);
setHasKubeConfig(false);
setFormData({ ...emptyForm });
setNameError('');
}
}, [open, editingCluster]);
const handleNameChange = (value: string) => {
setFormData({ ...formData, name: value });
if (!value.trim()) {
setNameError('');
} else if (!NAME_RE.test(value)) {
setNameError('只允许英文、数字和连字符(-),首尾不能是连字符');
} else {
setNameError('');
}
};
const parseApiServer = (yaml: string): string => {
const match = yaml.match(/^\s*server:\s*(\S+)\s*$/m);
return match ? match[1] : '';
};
const handleKubeConfigChange = (value: string) => {
const server = parseApiServer(value);
setFormData((prev) => ({
...prev,
kube_config: value,
api_server: server || prev.api_server,
}));
};
const handleSubmit = async () => {
if (!formData.name.trim() || !NAME_RE.test(formData.name.trim())) {
toast.error('请输入有效的集群名称(英文/数字/连字符)'); return;
}
try {
setSubmitting(true);
const payload = {
name: formData.name.trim(),
code: formData.name.trim(), // 名称即 code
version: formData.version,
description: formData.description,
config_type: formData.config_type,
api_server: formData.api_server,
token: formData.config_type === 0 ? formData.token : '',
kube_config: formData.config_type === 1 ? formData.kube_config : '',
};
if (editingCluster) {
await kubernetesApi.updateCluster(editingCluster.id, payload);
} else {
await kubernetesApi.createCluster(payload);
}
onOpenChange(false);
onSaved();
} catch (e) {
console.error('保存集群失败:', e);
} finally {
setSubmitting(false);
}
};
const canSubmit = submitting || !formData.name.trim() || !!nameError;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-2xl">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editingCluster ? '编辑集群' : '添加集群'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
{editingCluster ? '修改集群接入配置' : '配置新的 Kubernetes 集群接入信息'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2 max-h-[60vh] overflow-y-auto pr-1">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">集群名称</Label>
<Input
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="如: prod-cluster"
className={`bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] ${nameError ? 'border-red-500' : ''}`}
/>
{nameError && <p className="text-xs text-red-400">{nameError}</p>}
<p className="text-xs text-[var(--dg-text-muted)]">
只允许英文、数字和连字符(-),首尾不能是连字符。名称将作为集群唯一标识。
</p>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">版本(可选)</Label>
<Input
value={formData.version}
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
placeholder="如: 1.28.0"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
<Input
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="集群用途描述"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">接入方式</Label>
<RadioGroup
value={String(formData.config_type)}
onValueChange={(v) => setFormData({ ...formData, config_type: Number(v) })}
className="flex gap-4"
>
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-hover)]">
<RadioGroupItem value="0" id="ct-0" />
<Label htmlFor="ct-0" className="cursor-pointer text-[var(--dg-text-primary)] font-normal">Token</Label>
</div>
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-hover)]">
<RadioGroupItem value="1" id="ct-1" />
<Label htmlFor="ct-1" className="cursor-pointer text-[var(--dg-text-primary)] font-normal">Kubeconfig</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">API Server</Label>
<Input
value={formData.api_server}
onChange={(e) => setFormData({ ...formData, api_server: e.target.value })}
placeholder="https://10.0.0.1:6443"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
{formData.config_type === 0 ? (
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">
Token {hasToken && <span className="text-emerald-400 text-xs ml-1">✓ 已配置(留空不修改)</span>}
</Label>
<Textarea
value={formData.token}
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
placeholder={hasToken ? '已配置,输入新值覆盖...' : '粘贴 Service Account Token...'}
rows={4}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
/>
</div>
) : (
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">
Kubeconfig {hasKubeConfig && <span className="text-emerald-400 text-xs ml-1">✓ 已配置(留空不修改)</span>}
</Label>
<Textarea
value={formData.kube_config}
onChange={(e) => handleKubeConfigChange(e.target.value)}
placeholder={hasKubeConfig ? '已配置,输入新值覆盖...' : 'apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://10.0.0.1:6443\n ...'}
rows={10}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
/>
{!hasKubeConfig && (
<p className="text-xs text-[var(--dg-text-muted)]">
粘贴 kubeconfig 内容后将自动解析 API Server 地址
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => { onOpenChange(false); }}
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
>
取消
</Button>
<Button
onClick={handleSubmit}
disabled={canSubmit}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
{submitting ? '保存中...' : editingCluster ? '保存' : '添加'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}