This commit is contained in:
dengqichen 2025-12-12 18:27:58 +08:00
parent 0ed6c1e126
commit c226f89ebe
10 changed files with 1479 additions and 3 deletions

View File

@ -43,6 +43,7 @@ const EditDialog: React.FC<EditDialogProps> = ({
// 标记是否有原始密码和Token用于判断是否需要提交
const [hasOriginalPassword, setHasOriginalPassword] = useState(false);
const [hasOriginalToken, setHasOriginalToken] = useState(false);
const [hasOriginalConfig, setHasOriginalConfig] = useState(false);
// 掩码常量
const MASK = '********';
@ -50,12 +51,14 @@ const EditDialog: React.FC<EditDialogProps> = ({
useEffect(() => {
if (open) {
if (record) {
// 判断是否有密码/Token(无论返回什么值,只要不为空就认为有)
// 判断是否有密码/Token/Config(无论返回什么值,只要不为空就认为有)
const hasPwd = !!record.password && record.password.trim() !== '';
const hasTkn = !!record.token && record.token.trim() !== '';
const hasCfg = !!record.config && record.config.trim() !== '';
setHasOriginalPassword(hasPwd);
setHasOriginalToken(hasTkn);
setHasOriginalConfig(hasCfg);
setFormData({
name: record.name,
@ -67,6 +70,8 @@ const EditDialog: React.FC<EditDialogProps> = ({
password: hasPwd ? MASK : '',
// 如果有Token显示掩码否则显示空
token: hasTkn ? MASK : '',
// 如果有Config显示掩码否则显示空
config: hasCfg ? MASK : '',
sort: record.sort,
remark: record.remark,
enabled: record.enabled,
@ -74,6 +79,7 @@ const EditDialog: React.FC<EditDialogProps> = ({
} else {
setHasOriginalPassword(false);
setHasOriginalToken(false);
setHasOriginalConfig(false);
setFormData({ enabled: true, sort: 1, authType: 'BASIC' as AuthType });
}
}
@ -132,6 +138,19 @@ const EditDialog: React.FC<EditDialogProps> = ({
}
}
if (formData.authType === 'KUBECONFIG') {
// 新建时必须输入Kubeconfig
if (!record && !formData.config) {
toast({ title: '提示', description: '请输入Kubeconfig配置', variant: 'destructive' });
return;
}
// 编辑时如果清空了原有Config掩码需要提示
if (record && hasOriginalConfig && !formData.config?.trim()) {
toast({ title: '提示', description: 'Kubeconfig不能为空请输入新配置或保持原配置不变', variant: 'destructive' });
return;
}
}
// 准备提交数据
const submitData: ExternalSystemRequest = {
...formData as ExternalSystemRequest,
@ -159,6 +178,17 @@ const EditDialog: React.FC<EditDialogProps> = ({
}
}
// 处理Config如果值还是掩码说明没修改提交掩码否则提交新值
if (formData.authType === 'KUBECONFIG') {
if (record && formData.config === MASK) {
// 没有修改,保持掩码
submitData.config = MASK;
} else {
// 修改了或新建提交新Config
submitData.config = formData.config;
}
}
if (record) {
await updateExternalSystem(record.id, submitData);
toast({ title: '更新成功', description: `系统 "${formData.name}" 已更新` });
@ -206,6 +236,7 @@ const EditDialog: React.FC<EditDialogProps> = ({
<SelectItem value="JENKINS">Jenkins</SelectItem>
<SelectItem value="GIT">Git</SelectItem>
<SelectItem value="ZENTAO"></SelectItem>
<SelectItem value="K8S">K8S</SelectItem>
</SelectContent>
</Select>
</div>
@ -233,6 +264,7 @@ const EditDialog: React.FC<EditDialogProps> = ({
<SelectItem value="BASIC"></SelectItem>
<SelectItem value="TOKEN"></SelectItem>
<SelectItem value="OAUTH">OAuth2</SelectItem>
<SelectItem value="KUBECONFIG">Kubeconfig</SelectItem>
</SelectContent>
</Select>
</div>
@ -288,6 +320,30 @@ const EditDialog: React.FC<EditDialogProps> = ({
</div>
)}
{formData.authType === 'KUBECONFIG' && (
<div className="grid gap-2">
<Label htmlFor="config">
Kubeconfig配置 {!record && '*'}
{record && hasOriginalConfig && (
<span className="ml-2 text-xs text-muted-foreground">
()
</span>
)}
</Label>
<Textarea
id="config"
value={formData.config || ''}
onChange={(e) => setFormData({ ...formData, config: e.target.value })}
placeholder={record ? (hasOriginalConfig ? '保持不变或输入新配置' : '请粘贴完整的kubeconfig文件内容') : '请粘贴完整的kubeconfig文件内容'}
rows={12}
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
kubeconfig YAML配置文件内容
</p>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="sort"> *</Label>
<Input

View File

@ -79,6 +79,7 @@ const ExternalPage: React.FC = () => {
JENKINS: { label: 'Jenkins', variant: 'default' as const },
GIT: { label: 'Git', variant: 'secondary' as const },
ZENTAO: { label: '禅道', variant: 'outline' as const },
K8S: { label: 'K8S', variant: 'default' as const },
};
return typeMap[type] || { label: type, variant: 'outline' as const };
};
@ -89,6 +90,7 @@ const ExternalPage: React.FC = () => {
BASIC: '用户名密码',
TOKEN: '令牌',
OAUTH: 'OAuth2',
KUBECONFIG: 'Kubeconfig',
};
return authTypeMap[authType];
};
@ -105,6 +107,7 @@ const ExternalPage: React.FC = () => {
{ label: 'Jenkins', value: 'JENKINS' },
{ label: 'Git', value: 'GIT' },
{ label: '禅道', value: 'ZENTAO' },
{ label: 'K8S', value: 'K8S' },
],
},
{

View File

@ -4,14 +4,16 @@ import {BaseQuery, BaseResponse} from "@/types/base";
export enum SystemType {
JENKINS = 'JENKINS',
GIT = 'GIT',
ZENTAO = 'ZENTAO'
ZENTAO = 'ZENTAO',
K8S = 'K8S'
}
// 认证方式枚举
export enum AuthType {
BASIC = 'BASIC',
TOKEN = 'TOKEN',
OAUTH = 'OAUTH'
OAUTH = 'OAUTH',
KUBECONFIG = 'KUBECONFIG'
}
// 同步状态枚举

View File

@ -0,0 +1,335 @@
import React, { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Trash2, Loader2, Package, Tag, FileCode, Copy, Check } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { YamlViewerDialog } from './YamlViewerDialog';
import type { K8sDeploymentResponse } from '../types';
import { getK8sDeploymentByNamespace, deleteK8sDeployment } from '../service';
import dayjs from 'dayjs';
interface DeploymentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
namespaceName: string;
namespaceId: number;
externalSystemId: number;
}
export const DeploymentDialog: React.FC<DeploymentDialogProps> = ({
open,
onOpenChange,
namespaceName,
namespaceId,
externalSystemId,
}) => {
const { toast } = useToast();
const [deployments, setDeployments] = useState<K8sDeploymentResponse[]>([]);
const [loading, setLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deploymentToDelete, setDeploymentToDelete] = useState<K8sDeploymentResponse | null>(null);
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
const [yamlDialogOpen, setYamlDialogOpen] = useState(false);
const [selectedDeployment, setSelectedDeployment] = useState<K8sDeploymentResponse | null>(null);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
const loadDeployments = async () => {
setLoading(true);
try {
const data = await getK8sDeploymentByNamespace(externalSystemId, namespaceId);
setDeployments(data || []);
} catch (error) {
console.error('加载Deployment失败:', error);
toast({
title: '加载失败',
description: '加载Deployment列表失败',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open && namespaceId && externalSystemId) {
loadDeployments();
}
}, [open, namespaceId, externalSystemId]);
const handleDelete = (deployment: K8sDeploymentResponse) => {
setDeploymentToDelete(deployment);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!deploymentToDelete) return;
await deleteK8sDeployment(deploymentToDelete.id);
};
const handleViewLabels = (deployment: K8sDeploymentResponse, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedDeployment(deployment);
setLabelDialogOpen(true);
};
const handleViewYaml = (deployment: K8sDeploymentResponse, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedDeployment(deployment);
setYamlDialogOpen(true);
};
const handleCopyLabel = (key: string, value: string, index: number) => {
const text = `${key}:${value}`;
navigator.clipboard.writeText(text).then(() => {
setCopiedIndex(index);
toast({
title: '已复制',
description: `已复制标签到剪贴板`,
});
setTimeout(() => setCopiedIndex(null), 2000);
});
};
const handleCopyAll = () => {
if (!selectedDeployment?.labels || Object.keys(selectedDeployment.labels).length === 0) return;
const allLabelsText = Object.entries(selectedDeployment.labels)
.map(([key, value]) => `${key}:${value}`)
.join('\n');
navigator.clipboard.writeText(allLabelsText).then(() => {
setCopiedAll(true);
const labelCount = Object.keys(selectedDeployment.labels!).length;
toast({
title: '已复制',
description: `已复制全部${labelCount}个标签`,
});
setTimeout(() => setCopiedAll(false), 2000);
});
};
const getReplicaStatus = (deployment: K8sDeploymentResponse) => {
const ready = deployment.readyReplicas ?? 0;
const desired = deployment.replicas ?? 0;
if (desired === 0) {
return { variant: 'secondary' as const, text: '0/0' };
}
if (ready === desired) {
return { variant: 'default' as const, text: `${ready}/${desired}` };
} else if (ready > 0) {
return { variant: 'secondary' as const, text: `${ready}/${desired}` };
} else {
return { variant: 'destructive' as const, text: `${ready}/${desired}` };
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[90vw] max-h-[85vh] flex flex-col">
<DialogHeader>
<div className="flex items-center gap-2">
<Package className="h-5 w-5 text-primary" />
<DialogTitle>: {namespaceName} Deployment</DialogTitle>
</div>
</DialogHeader>
<DialogBody className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : deployments.length === 0 ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Deployment
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{deployments.map((deployment) => {
const status = getReplicaStatus(deployment);
return (
<div
key={deployment.id}
className="border rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-3">
<h4 className="font-semibold truncate" title={deployment.deploymentName}>
{deployment.deploymentName}
</h4>
<Badge variant={status.variant}>
{status.text}
</Badge>
</div>
<div className="space-y-2 text-sm text-muted-foreground">
{deployment.image && (
<div className="truncate" title={deployment.image}>
<span className="font-medium">:</span> {deployment.image}
</div>
)}
{deployment.labels && Object.keys(deployment.labels).length > 0 && (
<div>
<span className="font-medium">:</span> {Object.keys(deployment.labels).length}
</div>
)}
{deployment.k8sUpdateTime && (
<div className="text-xs">
: {dayjs(deployment.k8sUpdateTime).format('YYYY-MM-DD HH:mm')}
</div>
)}
</div>
<div className="mt-4 pt-3 border-t space-y-2">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={(e) => handleViewLabels(deployment, e)}
className="flex-1"
>
<Tag className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => handleViewYaml(deployment, e)}
className="flex-1"
>
<FileCode className="h-4 w-4 mr-1" />
YAML
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(deployment)}
className="text-destructive hover:text-destructive w-full"
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
);
})}
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="确认删除"
description={`确定要删除Deployment"${deploymentToDelete?.deploymentName}"吗?此操作无法撤销。`}
item={deploymentToDelete}
onConfirm={confirmDelete}
onSuccess={() => {
toast({
title: '删除成功',
description: `Deployment"${deploymentToDelete?.deploymentName}"已删除`,
});
setDeploymentToDelete(null);
loadDeployments();
}}
variant="destructive"
confirmText="确定"
/>
{/* 标签Dialog */}
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
- {selectedDeployment?.deploymentName}
</DialogTitle>
<Button
variant="outline"
size="sm"
onClick={handleCopyAll}
className="flex items-center gap-2"
>
{copiedAll ? (
<>
<Check className="h-4 w-4 text-green-600" />
</>
) : (
<>
<Copy className="h-4 w-4" />
</>
)}
</Button>
</div>
</DialogHeader>
<DialogBody className="max-h-[60vh] overflow-y-auto">
{selectedDeployment?.labels && Object.keys(selectedDeployment.labels).length > 0 ? (
<div className="space-y-2">
{Object.entries(selectedDeployment.labels).map(([key, value], index) => (
<div
key={key}
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border hover:border-primary/50 transition-colors bg-muted/30"
>
<div className="flex-1 min-w-0 font-mono text-sm">
<span className="text-blue-600 dark:text-blue-400 font-semibold">{key}</span>
<span className="text-muted-foreground">:</span>
<span className="text-foreground">{value}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyLabel(key, value, index)}
className="flex-shrink-0"
>
{copiedIndex === index ? (
<>
<Check className="h-4 w-4 mr-1 text-green-600" />
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
</>
)}
</Button>
</div>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-8">
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
{/* YAML查看器 */}
<YamlViewerDialog
open={yamlDialogOpen}
onOpenChange={setYamlDialogOpen}
title={`Deployment: ${selectedDeployment?.deploymentName} - YAML配置`}
yamlContent={selectedDeployment?.yamlConfig}
/>
</>
);
};

View File

@ -0,0 +1,222 @@
import React, { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Database, Tag, Copy, Check, FileCode } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { YamlViewerDialog } from './YamlViewerDialog';
import type { K8sNamespaceResponse } from '../types';
interface NamespaceCardProps {
namespace: K8sNamespaceResponse;
onClick: () => void;
}
export const NamespaceCard: React.FC<NamespaceCardProps> = ({ namespace, onClick }) => {
const { toast } = useToast();
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
const [yamlDialogOpen, setYamlDialogOpen] = useState(false);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
const labelCount = namespace.labels ? Object.keys(namespace.labels).length : 0;
const deploymentCount = namespace.deploymentCount ?? 0;
const handleCopyLabel = (key: string, value: string, index: number) => {
const text = `${key}:${value}`;
navigator.clipboard.writeText(text).then(() => {
setCopiedIndex(index);
toast({
title: '已复制',
description: `已复制标签到剪贴板`,
});
setTimeout(() => setCopiedIndex(null), 2000);
});
};
const handleCopyAll = () => {
if (!namespace.labels || Object.keys(namespace.labels).length === 0) return;
const allLabelsText = Object.entries(namespace.labels)
.map(([key, value]) => `${key}:${value}`)
.join('\n');
navigator.clipboard.writeText(allLabelsText).then(() => {
setCopiedAll(true);
toast({
title: '已复制',
description: `已复制全部${labelCount}个标签`,
});
setTimeout(() => setCopiedAll(false), 2000);
});
};
const handleLabelClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (labelCount > 0) {
setLabelDialogOpen(true);
}
};
const handleYamlClick = (e: React.MouseEvent) => {
e.stopPropagation();
setYamlDialogOpen(true);
};
return (
<>
<Card
className="cursor-pointer hover:shadow-lg transition-all hover:border-primary/50 border-border"
onClick={onClick}
>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex-shrink-0">
<Database className="h-5 w-5 text-primary" />
</div>
<h3 className="font-medium text-base truncate" title={namespace.namespaceName}>
{namespace.namespaceName}
</h3>
</div>
{namespace.status && (
<Badge
variant={namespace.status === 'Active' ? 'default' : 'secondary'}
className="text-xs flex-shrink-0 ml-2"
>
{namespace.status}
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{deploymentCount}</span>
<span>Deployment</span>
</div>
<div
className="flex items-center gap-1.5 cursor-pointer hover:text-foreground transition-colors"
onClick={handleLabelClick}
>
<span className="font-medium text-foreground">{labelCount}</span>
<span></span>
{labelCount > 0 && <Tag className="h-3 w-3" />}
</div>
</div>
{namespace.createTime && (
<div className="mt-3 pt-3 border-t text-xs text-muted-foreground">
{new Date(namespace.createTime).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</div>
)}
<div className="mt-3 pt-3 border-t flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleLabelClick}
className="flex-1"
>
<Tag className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleYamlClick}
className="flex-1"
>
<FileCode className="h-4 w-4 mr-1" />
YAML
</Button>
</div>
</CardContent>
</Card>
{/* 标签Dialog */}
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
</DialogTitle>
<Button
variant="outline"
size="sm"
onClick={handleCopyAll}
className="flex items-center gap-2"
>
{copiedAll ? (
<>
<Check className="h-4 w-4 text-green-600" />
</>
) : (
<>
<Copy className="h-4 w-4" />
</>
)}
</Button>
</div>
</DialogHeader>
<DialogBody className="max-h-[60vh] overflow-y-auto">
{namespace.labels && Object.keys(namespace.labels).length > 0 ? (
<div className="space-y-2">
{Object.entries(namespace.labels).map(([key, value], index) => (
<div
key={key}
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border hover:border-primary/50 transition-colors bg-muted/30"
>
<div className="flex-1 min-w-0 font-mono text-sm">
<span className="text-blue-600 dark:text-blue-400 font-semibold">{key}</span>
<span className="text-muted-foreground">:</span>
<span className="text-foreground">{value}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyLabel(key, value, index)}
className="flex-shrink-0"
>
{copiedIndex === index ? (
<>
<Check className="h-4 w-4 mr-1 text-green-600" />
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
</>
)}
</Button>
</div>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-8">
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
{/* YAML查看器 */}
<YamlViewerDialog
open={yamlDialogOpen}
onOpenChange={setYamlDialogOpen}
title={`命名空间: ${namespace.namespaceName} - YAML配置`}
yamlContent={namespace.yamlConfig}
/>
</>
);
};

View File

@ -0,0 +1,203 @@
import React, { useRef, useMemo } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { RefreshCw, Loader2 } from 'lucide-react';
import { PaginatedTable, type ColumnDef, type SearchFieldDef, type PaginatedTableRef } from '@/components/ui/paginated-table';
import type { K8sSyncHistoryResponse, K8sSyncHistoryQuery } from '../types';
import { K8sSyncTypeLabels, K8sSyncStatusLabels, K8sSyncType, K8sSyncStatus } from '../types';
import { getK8sSyncHistoryPage } from '../service';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
dayjs.extend(duration);
interface SyncHistoryTabProps {
externalSystemId: number | null;
}
export const SyncHistoryTab: React.FC<SyncHistoryTabProps> = ({ externalSystemId }) => {
const tableRef = useRef<PaginatedTableRef<K8sSyncHistoryResponse>>(null);
// 刷新列表
const handleRefresh = () => {
tableRef.current?.refresh();
};
// 包装 fetchFn自动带上集群ID
const fetchData = async (query: K8sSyncHistoryQuery) => {
if (!externalSystemId) {
return {
content: [],
totalElements: 0,
totalPages: 0,
size: query.pageSize || 10,
number: query.pageNum || 0,
} as any;
}
return getK8sSyncHistoryPage({
...query,
externalSystemId,
});
};
// 计算耗时
const calculateDuration = (startTime?: string, endTime?: string) => {
if (!startTime || !endTime) return '-';
const start = dayjs(startTime);
const end = dayjs(endTime);
const diff = end.diff(start);
if (diff < 1000) {
return `${diff}毫秒`;
} else if (diff < 60000) {
return `${Math.floor(diff / 1000)}`;
} else {
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
return `${minutes}${seconds}`;
}
};
// 搜索字段定义
const searchFields: SearchFieldDef[] = useMemo(() => [
{
key: 'syncType',
type: 'select',
placeholder: '同步类型',
width: 'w-[150px]',
options: Object.entries(K8sSyncTypeLabels).map(([key, value]) => ({
label: value.label,
value: key,
})),
},
{
key: 'status',
type: 'select',
placeholder: '同步状态',
width: 'w-[150px]',
options: Object.entries(K8sSyncStatusLabels).map(([key, value]) => ({
label: value.label,
value: key,
})),
},
], []);
// 列定义
const columns: ColumnDef<K8sSyncHistoryResponse>[] = useMemo(() => [
{ key: 'id', title: 'ID', dataIndex: 'id', width: '80px' },
{
key: 'number',
title: '同步编号',
dataIndex: 'number',
width: '200px',
render: (value) => (
<code className="px-2 py-1 rounded bg-muted text-sm font-mono">
{value}
</code>
),
},
{
key: 'syncType',
title: '同步类型',
width: '120px',
render: (_, record) => {
const typeInfo = K8sSyncTypeLabels[record.syncType];
return (
<Badge variant="outline" className={typeInfo.color}>
{typeInfo.label}
</Badge>
);
},
},
{
key: 'status',
title: '状态',
width: '100px',
render: (_, record) => {
const statusInfo = K8sSyncStatusLabels[record.status];
return (
<Badge variant={statusInfo.variant}>
{record.status === K8sSyncStatus.RUNNING && (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
)}
{statusInfo.label}
</Badge>
);
},
},
{
key: 'startTime',
title: '开始时间',
dataIndex: 'startTime',
width: '180px',
render: (value) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
key: 'endTime',
title: '结束时间',
dataIndex: 'endTime',
width: '180px',
render: (value) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
key: 'duration',
title: '耗时',
width: '120px',
render: (_, record) => calculateDuration(record.startTime, record.endTime),
},
{
key: 'errorMessage',
title: '错误信息',
width: '300px',
render: (_, record) => {
if (!record.errorMessage) {
return <span className="text-muted-foreground">-</span>;
}
const maxLength = 50;
const displayText = record.errorMessage.length > maxLength
? record.errorMessage.substring(0, maxLength) + '...'
: record.errorMessage;
return (
<span
className="text-destructive text-xs cursor-help"
title={record.errorMessage}
>
{displayText}
</span>
);
},
},
], []);
// 工具栏
const toolbar = (
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
);
if (!externalSystemId) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
K8S集群
</div>
);
}
return (
<PaginatedTable<K8sSyncHistoryResponse, K8sSyncHistoryQuery>
ref={tableRef}
fetchFn={fetchData}
columns={columns}
searchFields={searchFields}
toolbar={toolbar}
rowKey="id"
minWidth="1200px"
emptyText="暂无同步历史"
/>
);
};

View File

@ -0,0 +1,63 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
} from '@/components/ui/dialog';
import { FileCode } from 'lucide-react';
import Editor from '@/components/Editor';
interface YamlViewerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
yamlContent?: string;
}
export const YamlViewerDialog: React.FC<YamlViewerDialogProps> = ({
open,
onOpenChange,
title,
yamlContent,
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[90vw] max-h-[85vh] flex flex-col">
<DialogHeader>
<div className="flex items-center gap-2">
<FileCode className="h-5 w-5 text-primary" />
<DialogTitle>{title}</DialogTitle>
</div>
</DialogHeader>
<DialogBody className="flex-1 overflow-hidden">
{yamlContent ? (
<div className="h-[calc(85vh-120px)] border rounded-md overflow-hidden">
<Editor
height="100%"
language="yaml"
value={yamlContent}
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'on',
folding: true,
wordWrap: 'on',
}}
theme="vs-dark"
/>
</div>
) : (
<div className="flex items-center justify-center h-64 text-muted-foreground">
YAML配置
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,291 @@
import React, { useState, useEffect } from 'react';
import { PageContainer } from '@/components/ui/page-container';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { RefreshCw, Loader2, Cloud, History as HistoryIcon } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { NamespaceCard } from './components/NamespaceCard';
import { DeploymentDialog } from './components/DeploymentDialog';
import { SyncHistoryTab } from './components/SyncHistoryTab';
import { getExternalSystemList } from '@/pages/Resource/External/List/service';
import { syncK8sNamespace, syncK8sDeployment, getK8sNamespaceList } from './service';
import type { ExternalSystemResponse } from '@/pages/Resource/External/List/types';
import type { K8sNamespaceResponse } from './types';
import { SystemType } from '@/pages/Resource/External/List/types';
const K8sManagement: React.FC = () => {
const { toast } = useToast();
const [k8sClusters, setK8sClusters] = useState<ExternalSystemResponse[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<number | null>(null);
const [namespaces, setNamespaces] = useState<K8sNamespaceResponse[]>([]);
const [syncingNamespace, setSyncingNamespace] = useState(false);
const [syncingDeployment, setSyncingDeployment] = useState(false);
const [loading, setLoading] = useState(true);
const [showHistory, setShowHistory] = useState(false);
const [selectedNamespace, setSelectedNamespace] = useState<K8sNamespaceResponse | null>(null);
const [deploymentDialogOpen, setDeploymentDialogOpen] = useState(false);
// 加载K8S集群列表
const loadK8sClusters = async () => {
try {
setLoading(true);
const clusters = await getExternalSystemList({ type: SystemType.K8S });
// 过滤出启用的集群
const enabledClusters = (clusters || []).filter(c => c.enabled);
setK8sClusters(enabledClusters);
// 如果有集群,默认选择第一个
if (enabledClusters.length > 0 && !selectedClusterId) {
setSelectedClusterId(enabledClusters[0].id);
}
} catch (error) {
console.error('加载K8S集群失败:', error);
toast({
title: '加载失败',
description: '加载K8S集群列表失败',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
// 加载命名空间列表
const loadNamespaces = async () => {
if (!selectedClusterId) return;
try {
const data = await getK8sNamespaceList(selectedClusterId);
setNamespaces(data || []);
} catch (error) {
console.error('加载命名空间失败:', error);
toast({
title: '加载失败',
description: '加载命名空间列表失败',
variant: 'destructive',
});
}
};
useEffect(() => {
loadK8sClusters();
}, []);
useEffect(() => {
if (selectedClusterId) {
loadNamespaces();
}
}, [selectedClusterId]);
// 同步命名空间
const handleSyncNamespace = async () => {
if (!selectedClusterId) {
toast({
title: '请选择集群',
description: '请先选择要同步的K8S集群',
variant: 'destructive',
});
return;
}
try {
setSyncingNamespace(true);
await syncK8sNamespace(selectedClusterId);
toast({
title: '同步已提交',
description: '命名空间同步任务已提交,正在后台执行',
});
} catch (error: any) {
toast({
title: '同步失败',
description: error.message || '命名空间同步失败',
variant: 'destructive',
});
} finally {
setSyncingNamespace(false);
}
};
// 同步Deployment
const handleSyncDeployment = async () => {
if (!selectedClusterId) {
toast({
title: '请选择集群',
description: '请先选择要同步的K8S集群',
variant: 'destructive',
});
return;
}
try {
setSyncingDeployment(true);
await syncK8sDeployment(selectedClusterId);
toast({
title: '同步已提交',
description: 'Deployment同步任务已提交正在后台执行',
});
} catch (error: any) {
toast({
title: '同步失败',
description: error.message || 'Deployment同步失败',
variant: 'destructive',
});
} finally {
setSyncingDeployment(false);
}
};
if (loading) {
return (
<PageContainer>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</PageContainer>
);
}
if (k8sClusters.length === 0) {
return (
<PageContainer>
<div className="flex flex-col items-center justify-center h-64">
<Cloud className="h-16 w-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">K8S集群</h3>
<p className="text-sm text-muted-foreground mb-4">
"三方系统管理"K8S集群
</p>
<Button onClick={() => window.location.href = '/resource/external'}>
</Button>
</div>
</PageContainer>
);
}
const handleNamespaceClick = (namespace: K8sNamespaceResponse) => {
setSelectedNamespace(namespace);
setDeploymentDialogOpen(true);
};
return (
<PageContainer>
<div className="mb-6">
<h2 className="text-3xl font-bold tracking-tight">K8S管理</h2>
<p className="text-muted-foreground mt-1">
Kubernetes集群资源Deployment
</p>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cloud className="h-5 w-5" />
K8S集群
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="flex-1 max-w-xs">
<Select
value={selectedClusterId ? String(selectedClusterId) : undefined}
onValueChange={(value) => setSelectedClusterId(Number(value))}
>
<SelectTrigger>
<SelectValue placeholder="选择K8S集群" />
</SelectTrigger>
<SelectContent>
{k8sClusters.map((cluster) => (
<SelectItem key={cluster.id} value={String(cluster.id)}>
{cluster.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleSyncNamespace}
disabled={!selectedClusterId || syncingNamespace}
variant="outline"
>
{syncingNamespace ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
</Button>
<Button
onClick={handleSyncDeployment}
disabled={!selectedClusterId || syncingDeployment}
variant="outline"
>
{syncingDeployment ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Deployment
</Button>
<Button
onClick={() => setShowHistory(!showHistory)}
variant={showHistory ? "default" : "outline"}
>
<HistoryIcon className="h-4 w-4 mr-2" />
{showHistory ? '查看命名空间' : '同步历史'}
</Button>
</div>
</CardContent>
</Card>
{showHistory ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<SyncHistoryTab externalSystemId={selectedClusterId} />
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{namespaces.length === 0 ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
{selectedClusterId ? '暂无命名空间数据' : '请先选择K8S集群'}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{namespaces.map((namespace) => (
<NamespaceCard
key={namespace.id}
namespace={namespace}
onClick={() => handleNamespaceClick(namespace)}
/>
))}
</div>
)}
</CardContent>
</Card>
)}
{selectedNamespace && (
<DeploymentDialog
open={deploymentDialogOpen}
onOpenChange={setDeploymentDialogOpen}
namespaceName={selectedNamespace.namespaceName}
namespaceId={selectedNamespace.id}
externalSystemId={selectedClusterId!}
/>
)}
</PageContainer>
);
};
export default K8sManagement;

View File

@ -0,0 +1,144 @@
import request from '@/utils/request';
import type { Page } from '@/types/base';
import type {
K8sNamespaceResponse,
K8sNamespaceQuery,
K8sDeploymentResponse,
K8sDeploymentQuery,
K8sSyncHistoryResponse,
K8sSyncHistoryQuery,
} from './types';
// ==================== K8S命名空间接口 ====================
/**
*
*/
export const getK8sNamespacePage = async (
params: K8sNamespaceQuery
): Promise<Page<K8sNamespaceResponse>> => {
return request.get('/api/v1/k8s-namespace/page', { params });
};
/**
* ID查询命名空间列表
*/
export const getK8sNamespaceList = async (
externalSystemId: number
): Promise<K8sNamespaceResponse[]> => {
return request.get('/api/v1/k8s-namespace/list', {
params: { externalSystemId }
});
};
/**
* ID查询命名空间使getK8sNamespaceList
*/
export const getK8sNamespaceBySystem = async (
externalSystemId: number
): Promise<K8sNamespaceResponse[]> => {
return request.get(`/api/v1/k8s-namespace/by-system/${externalSystemId}`);
};
/**
* K8S命名空间
*/
export const syncK8sNamespace = async (externalSystemId: number): Promise<void> => {
return request.post('/api/v1/k8s-namespace/sync', null, {
params: { externalSystemId },
});
};
/**
*
*/
export const getK8sNamespace = async (id: number): Promise<K8sNamespaceResponse> => {
return request.get(`/api/v1/k8s-namespace/${id}`);
};
/**
*
*/
export const deleteK8sNamespace = async (id: number): Promise<void> => {
return request.delete(`/api/v1/k8s-namespace/${id}`);
};
// ==================== K8S Deployment接口 ====================
/**
* Deployment
*/
export const getK8sDeploymentPage = async (
params: K8sDeploymentQuery
): Promise<Page<K8sDeploymentResponse>> => {
return request.get('/api/v1/k8s-deployment/page', { params });
};
/**
* ID查询Deployment
*/
export const getK8sDeploymentBySystem = async (
externalSystemId: number
): Promise<K8sDeploymentResponse[]> => {
return request.get(`/api/v1/k8s-deployment/by-system/${externalSystemId}`);
};
/**
* ID查询Deployment
*/
export const getK8sDeploymentByNamespace = async (
externalSystemId: number,
namespaceId: number
): Promise<K8sDeploymentResponse[]> => {
return request.get(`/api/v1/k8s-deployment/by-namespace/${namespaceId}`, {
params: { externalSystemId }
});
};
/**
* K8S Deployment
*/
export const syncK8sDeployment = async (
externalSystemId: number,
namespaceId?: number
): Promise<void> => {
const params: any = { externalSystemId };
if (namespaceId) {
params.namespaceId = namespaceId;
}
return request.post('/api/v1/k8s-deployment/sync', null, { params });
};
/**
* Deployment
*/
export const getK8sDeployment = async (id: number): Promise<K8sDeploymentResponse> => {
return request.get(`/api/v1/k8s-deployment/${id}`);
};
/**
* Deployment
*/
export const deleteK8sDeployment = async (id: number): Promise<void> => {
return request.delete(`/api/v1/k8s-deployment/${id}`);
};
// ==================== K8S同步历史接口 ====================
/**
*
*/
export const getK8sSyncHistoryPage = async (
params: K8sSyncHistoryQuery
): Promise<Page<K8sSyncHistoryResponse>> => {
return request.get('/api/v1/k8s-sync-history/page', { params });
};
/**
* ID查询同步历史
*/
export const getK8sSyncHistoryBySystem = async (
externalSystemId: number
): Promise<K8sSyncHistoryResponse[]> => {
return request.get(`/api/v1/k8s-sync-history/by-system/${externalSystemId}`);
};

View File

@ -0,0 +1,157 @@
import type { BaseResponse, BaseQuery } from '@/types/base';
// ==================== 枚举类型 ====================
/**
* K8S同步类型
*/
export enum K8sSyncType {
/** 命名空间 */
NAMESPACE = 'NAMESPACE',
/** Deployment */
DEPLOYMENT = 'DEPLOYMENT',
}
/**
*
*/
export enum K8sSyncStatus {
/** 成功 */
SUCCESS = 'SUCCESS',
/** 失败 */
FAILED = 'FAILED',
/** 运行中 */
RUNNING = 'RUNNING',
}
// 标签映射
export const K8sSyncTypeLabels: Record<K8sSyncType, { label: string; color: string }> = {
[K8sSyncType.NAMESPACE]: { label: '命名空间', color: 'text-purple-600' },
[K8sSyncType.DEPLOYMENT]: { label: 'Deployment', color: 'text-orange-600' },
};
export const K8sSyncStatusLabels: Record<K8sSyncStatus, { label: string; variant: 'default' | 'destructive' | 'secondary' }> = {
[K8sSyncStatus.SUCCESS]: { label: '成功', variant: 'default' },
[K8sSyncStatus.FAILED]: { label: '失败', variant: 'destructive' },
[K8sSyncStatus.RUNNING]: { label: '运行中', variant: 'secondary' },
};
// ==================== K8S命名空间 ====================
/**
* K8S命名空间响应
*/
export interface K8sNamespaceResponse extends BaseResponse {
/** K8S集群ID */
externalSystemId: number;
/** K8S集群名称 */
externalSystemName?: string;
/** 命名空间名称 */
namespaceName: string;
/** 状态 */
status?: string;
/** 标签 */
labels?: Record<string, string>;
/** Deployment数量 */
deploymentCount?: number;
/** YAML配置 */
yamlConfig?: string;
}
/**
* K8S命名空间查询参数
*/
export interface K8sNamespaceQuery extends BaseQuery {
/** K8S集群ID */
externalSystemId?: number;
/** 命名空间名称 */
namespaceName?: string;
/** 状态 */
status?: string;
}
// ==================== K8S Deployment ====================
/**
* K8S Deployment响应
*/
export interface K8sDeploymentResponse extends BaseResponse {
/** K8S集群ID */
externalSystemId: number;
/** K8S集群名称 */
externalSystemName?: string;
/** 命名空间ID */
namespaceId: number;
/** 命名空间名称 */
namespaceName?: string;
/** Deployment名称 */
deploymentName: string;
/** 期望副本数 */
replicas?: number;
/** 可用副本数 */
availableReplicas?: number;
/** 就绪副本数 */
readyReplicas?: number;
/** 容器镜像 */
image?: string;
/** 标签 */
labels?: Record<string, string>;
/** 选择器 */
selector?: Record<string, string>;
/** K8S中的创建时间 */
k8sCreateTime?: string;
/** K8S中的更新时间 */
k8sUpdateTime?: string;
/** YAML配置 */
yamlConfig?: string;
}
/**
* K8S Deployment查询参数
*/
export interface K8sDeploymentQuery extends BaseQuery {
/** K8S集群ID */
externalSystemId?: number;
/** 命名空间ID */
namespaceId?: number;
/** Deployment名称 */
deploymentName?: string;
/** 镜像 */
image?: string;
}
// ==================== K8S同步历史 ====================
/**
* K8S同步历史响应
*/
export interface K8sSyncHistoryResponse extends BaseResponse {
/** 同步编号 */
number: string;
/** 同步类型 */
syncType: K8sSyncType;
/** 同步状态 */
status: K8sSyncStatus;
/** 开始时间 */
startTime: string;
/** 结束时间 */
endTime?: string;
/** 错误信息 */
errorMessage?: string;
/** K8S集群ID */
externalSystemId: number;
/** K8S集群名称 */
externalSystemName?: string;
}
/**
* K8S同步历史查询参数
*/
export interface K8sSyncHistoryQuery extends BaseQuery {
/** K8S集群ID */
externalSystemId?: number;
/** 同步类型 */
syncType?: K8sSyncType;
/** 同步状态 */
status?: K8sSyncStatus;
}