1.30
This commit is contained in:
parent
0ed6c1e126
commit
c226f89ebe
@ -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
|
||||
|
||||
@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
// 同步状态枚举
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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="暂无同步历史"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
291
frontend/src/pages/Resource/K8s/List/index.tsx
Normal file
291
frontend/src/pages/Resource/K8s/List/index.tsx
Normal 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;
|
||||
144
frontend/src/pages/Resource/K8s/List/service.ts
Normal file
144
frontend/src/pages/Resource/K8s/List/service.ts
Normal 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}`);
|
||||
};
|
||||
157
frontend/src/pages/Resource/K8s/List/types.ts
Normal file
157
frontend/src/pages/Resource/K8s/List/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user