ocdp v1
This commit is contained in:
@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Cluster Configuration Form Component
|
||||
* For adding and editing cluster configurations
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Server, Key, FileText } from "lucide-react";
|
||||
import type { ClusterConfig } from "@/core/types";
|
||||
import { ValidationErrors } from "@/shared/utils";
|
||||
import { ButtonText, LabelText } from "@/shared/constants";
|
||||
|
||||
interface ClusterFormProps {
|
||||
cluster?: ClusterConfig;
|
||||
onSave: (data: Omit<ClusterConfig, "id" | "createdAt" | "updatedAt">) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
cluster,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: cluster?.name ?? "",
|
||||
host: cluster?.host ?? "",
|
||||
caData: cluster?.caData ?? "",
|
||||
certData: cluster?.certData ?? "",
|
||||
keyData: cluster?.keyData ?? "",
|
||||
token: cluster?.token ?? "",
|
||||
description: cluster?.description ?? "",
|
||||
});
|
||||
|
||||
// 新证书输入(编辑模式)
|
||||
const [newCaData, setNewCaData] = useState<string>("");
|
||||
const [newCertData, setNewCertData] = useState<string>("");
|
||||
const [newKeyData, setNewKeyData] = useState<string>("");
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (cluster) {
|
||||
setFormData({
|
||||
name: cluster.name ?? "",
|
||||
host: cluster.host ?? "",
|
||||
caData: cluster.caData ?? "",
|
||||
certData: cluster.certData ?? "",
|
||||
keyData: cluster.keyData ?? "",
|
||||
token: cluster.token ?? "",
|
||||
description: cluster.description ?? "",
|
||||
});
|
||||
}
|
||||
}, [cluster]);
|
||||
|
||||
const handleChange = (
|
||||
field: keyof typeof formData,
|
||||
value: string
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
// Clear field error
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = ValidationErrors.REQUIRED_FIELD("Cluster name");
|
||||
}
|
||||
|
||||
if (!formData.host.trim()) {
|
||||
newErrors.host = ValidationErrors.REQUIRED_FIELD("API Server URL");
|
||||
} else if (!/^https?:\/\/.+/.test(formData.host.trim())) {
|
||||
newErrors.host = ValidationErrors.INVALID_URL;
|
||||
}
|
||||
|
||||
// 创建模式:必填证书
|
||||
if (!cluster) {
|
||||
if (!formData.caData.trim()) {
|
||||
newErrors.caData = ValidationErrors.REQUIRED_FIELD("CA Certificate");
|
||||
}
|
||||
if (!formData.certData.trim()) {
|
||||
newErrors.certData = ValidationErrors.REQUIRED_FIELD("Client Certificate");
|
||||
}
|
||||
if (!formData.keyData.trim()) {
|
||||
newErrors.keyData = ValidationErrors.REQUIRED_FIELD("Client Key");
|
||||
}
|
||||
}
|
||||
// 编辑模式:证书可选(留空保持不变)
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (validate()) {
|
||||
// 准备提交数据 - 使用 OpenAPI 生成的 camelCase 字段名
|
||||
const submitData: Record<string, string> = {
|
||||
name: formData.name.trim(),
|
||||
host: formData.host.trim(),
|
||||
};
|
||||
|
||||
// 只添加非空的 description
|
||||
if (formData.description.trim()) {
|
||||
submitData.description = formData.description.trim();
|
||||
}
|
||||
|
||||
if (cluster) {
|
||||
// 编辑模式:只发送用户输入的新值(使用 camelCase)
|
||||
if (newCaData.trim()) submitData.caData = newCaData.trim();
|
||||
if (newCertData.trim()) submitData.certData = newCertData.trim();
|
||||
if (newKeyData.trim()) submitData.keyData = newKeyData.trim();
|
||||
} else {
|
||||
// 创建模式:发送所有必填字段(使用 camelCase)
|
||||
submitData.caData = formData.caData.trim();
|
||||
submitData.certData = formData.certData.trim();
|
||||
submitData.keyData = formData.keyData.trim();
|
||||
if (formData.token?.trim()) submitData.token = formData.token.trim();
|
||||
}
|
||||
|
||||
onSave(submitData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5">
|
||||
{/* Cluster Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-400" />
|
||||
{LabelText.NAME} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange("name", e.target.value)}
|
||||
className={`w-full bg-gray-900/60 border ${
|
||||
errors.name ? "border-red-500" : "border-gray-600"
|
||||
} rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none`}
|
||||
placeholder="e.g., Production Cluster"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-400">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Server URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-400" />
|
||||
API Server URL <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.host}
|
||||
onChange={(e) => handleChange("host", e.target.value)}
|
||||
className={`w-full bg-gray-900/60 border ${
|
||||
errors.host ? "border-red-500" : "border-gray-600"
|
||||
} rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none`}
|
||||
placeholder="e.g., https://kubernetes.example.com:6443"
|
||||
/>
|
||||
{errors.host && (
|
||||
<p className="mt-1 text-sm text-red-400">{errors.host}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Kubernetes API Server address (usually HTTPS protocol)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CA Certificate */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<Key className="w-4 h-4 text-green-400" />
|
||||
CA Certificate (Base64) {!cluster && <span className="text-red-400">*</span>}
|
||||
</label>
|
||||
{cluster ? (
|
||||
// 编辑模式:显示状态和新输入
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg">
|
||||
<span className="text-gray-400 text-sm">当前:</span>
|
||||
<span className="text-white font-mono text-xs">{formData.caData}</span>
|
||||
{cluster.hasCaData && (
|
||||
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full"></span>
|
||||
已配置(加密存储)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={newCaData}
|
||||
onChange={(e) => setNewCaData(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-gray-900/60 border border-gray-600 rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none"
|
||||
placeholder="粘贴新的 CA 证书以覆盖(留空保持不变)"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
💡 输入新证书以覆盖,留空则保持原证书不变
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// 创建模式:必填
|
||||
<>
|
||||
<textarea
|
||||
value={formData.caData}
|
||||
onChange={(e) => handleChange("caData", e.target.value)}
|
||||
rows={4}
|
||||
className={`w-full bg-gray-900/60 border ${
|
||||
errors.caData ? "border-red-500" : "border-gray-600"
|
||||
} rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none`}
|
||||
placeholder="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0t..."
|
||||
/>
|
||||
{errors.caData && (
|
||||
<p className="mt-1 text-sm text-red-400">{errors.caData}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Cluster CA certificate in base64 format (certificate-authority-data)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client Certificate */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<Key className="w-4 h-4 text-yellow-400" />
|
||||
Client Certificate (Base64) {!cluster && <span className="text-red-400">*</span>}
|
||||
</label>
|
||||
{cluster ? (
|
||||
// 编辑模式
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg">
|
||||
<span className="text-gray-400 text-sm">当前:</span>
|
||||
<span className="text-white font-mono text-xs">{formData.certData}</span>
|
||||
{cluster.hasCertData && (
|
||||
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full"></span>
|
||||
已配置(加密存储)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={newCertData}
|
||||
onChange={(e) => setNewCertData(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-gray-900/60 border border-gray-600 rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none"
|
||||
placeholder="粘贴新的客户端证书以覆盖(留空保持不变)"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
💡 输入新证书以覆盖,留空则保持原证书不变
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={formData.certData}
|
||||
onChange={(e) => handleChange("certData", e.target.value)}
|
||||
rows={4}
|
||||
className={`w-full bg-gray-900/60 border ${
|
||||
errors.certData ? "border-red-500" : "border-gray-600"
|
||||
} rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none`}
|
||||
placeholder="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0t..."
|
||||
/>
|
||||
{errors.certData && (
|
||||
<p className="mt-1 text-sm text-red-400">{errors.certData}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Client certificate in base64 format (client-certificate-data)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<Key className="w-4 h-4 text-red-400" />
|
||||
Client Key (Base64) {!cluster && <span className="text-red-400">*</span>}
|
||||
</label>
|
||||
{cluster ? (
|
||||
// 编辑模式
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg">
|
||||
<span className="text-gray-400 text-sm">当前:</span>
|
||||
<span className="text-white font-mono text-xs">{formData.keyData}</span>
|
||||
{cluster.hasKeyData && (
|
||||
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full"></span>
|
||||
已配置(加密存储)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={newKeyData}
|
||||
onChange={(e) => setNewKeyData(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-gray-900/60 border border-gray-600 rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none"
|
||||
placeholder="粘贴新的客户端密钥以覆盖(留空保持不变)"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
💡 输入新密钥以覆盖,留空则保持原密钥不变
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={formData.keyData}
|
||||
onChange={(e) => handleChange("keyData", e.target.value)}
|
||||
rows={4}
|
||||
className={`w-full bg-gray-900/60 border ${
|
||||
errors.keyData ? "border-red-500" : "border-gray-600"
|
||||
} rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none`}
|
||||
placeholder="LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBL..."
|
||||
/>
|
||||
{errors.keyData && (
|
||||
<p className="mt-1 text-sm text-red-400">{errors.keyData}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Client private key in base64 format (client-key-data)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-gray-400" />
|
||||
{LabelText.DESCRIPTION} (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange("description", e.target.value)}
|
||||
rows={2}
|
||||
className="w-full bg-gray-900/60 border border-gray-600 rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||
placeholder="Cluster description (e.g., purpose, environment)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 pt-3 sm:pt-4 border-t border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition text-sm sm:text-base"
|
||||
>
|
||||
{ButtonText.CANCEL}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition text-sm sm:text-base"
|
||||
>
|
||||
{cluster ? ButtonText.SAVE : `${ButtonText.ADD} ${LabelText.CLUSTER}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Cluster Configuration List Component
|
||||
* Display cluster list with edit and delete actions
|
||||
*/
|
||||
import React from "react";
|
||||
import { Server, Edit, Trash2, Key, ExternalLink } from "lucide-react";
|
||||
import type { ClusterConfig } from "@/core/types";
|
||||
import { LoadingText, EmptyText } from "@/shared/constants";
|
||||
|
||||
interface ClusterListProps {
|
||||
clusters: ClusterConfig[];
|
||||
loading: boolean;
|
||||
onEdit: (cluster: ClusterConfig) => void;
|
||||
onDelete: (cluster: ClusterConfig) => void;
|
||||
}
|
||||
|
||||
export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
clusters,
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-400"></div>
|
||||
<p className="text-gray-400 mt-4">{LoadingText.LOADING_CLUSTERS}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (clusters.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<Server className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg mb-2">{EmptyText.NO_CLUSTERS}</p>
|
||||
<p className="text-gray-500 text-sm">{EmptyText.NO_CLUSTERS_DESC}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{clusters.map((cluster) => (
|
||||
<div
|
||||
key={cluster.id}
|
||||
className="bg-gray-800/50 border border-gray-700 rounded-lg p-5 hover:bg-gray-800 hover:border-gray-600 transition group"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-1">
|
||||
<Server className="w-5 h-5 text-blue-400" />
|
||||
{cluster.name}
|
||||
</h3>
|
||||
{cluster.description && (
|
||||
<p className="text-sm text-gray-400">{cluster.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => onEdit(cluster)}
|
||||
className="p-2 text-gray-400 hover:text-blue-400 hover:bg-blue-400/10 rounded-lg transition"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(cluster)}
|
||||
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ExternalLink className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500 mb-0.5">API Server URL</p>
|
||||
<p className="text-sm text-gray-300 font-mono truncate" title={cluster.host}>
|
||||
{cluster.host}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Key className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500 mb-0.5">CA Certificate</p>
|
||||
<p className={`text-xs ${cluster.hasCaData ? "text-green-400" : "text-gray-500"}`}>
|
||||
{cluster.hasCaData ? "✓ Configured" : "✗ Not Configured"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Key className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500 mb-0.5">Client Cert</p>
|
||||
<p className={`text-xs ${cluster.hasCertData ? "text-yellow-400" : "text-gray-500"}`}>
|
||||
{cluster.hasCertData ? "✓ Configured" : "✗ Not Configured"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Key className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500 mb-0.5">Client Key</p>
|
||||
<p className={`text-xs ${cluster.hasKeyData ? "text-red-400" : "text-gray-500"}`}>
|
||||
{cluster.hasKeyData ? "✓ Configured" : "✗ Not Configured"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{cluster.createdAt && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-700/50">
|
||||
<p className="text-xs text-gray-500">
|
||||
Created: {new Date(cluster.createdAt).toLocaleString("en-US")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
frontend/src/features/configuration/clusters/index.ts
Normal file
12
frontend/src/features/configuration/clusters/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Cluster Management Feature
|
||||
* 集群配置管理功能
|
||||
*/
|
||||
|
||||
// Export pages
|
||||
export { default as ClusterConfigPage } from './pages/ClusterConfigPage';
|
||||
|
||||
// Export components
|
||||
export { ClusterForm } from './components/ClusterForm';
|
||||
export { ClusterList } from './components/ClusterList';
|
||||
|
||||
@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Cluster Configuration Page
|
||||
* Manage Kubernetes cluster configurations
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, RefreshCw, Server } from "lucide-react";
|
||||
import { useToast, Modal, Button, PageHeader } from "@/shared";
|
||||
import { ClusterErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
import { ClusterForm } from "../components/ClusterForm";
|
||||
import { ClusterList } from "../components/ClusterList";
|
||||
import {
|
||||
listClusters,
|
||||
createCluster,
|
||||
updateCluster,
|
||||
deleteCluster,
|
||||
} from "@/api";
|
||||
import type { ClusterConfig, ClusterResponse } from "@/core/types";
|
||||
|
||||
const ClusterConfigPage: React.FC = () => {
|
||||
const { success, error: toastError, info: toastInfo } = useToast();
|
||||
|
||||
const [clusters, setClusters] = useState<ClusterConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingCluster, setEditingCluster] = useState<ClusterConfig | undefined>(undefined);
|
||||
|
||||
// Load clusters
|
||||
const loadClusters = async (isMounted = { current: true }, isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
let succeeded = false;
|
||||
try {
|
||||
const data = await listClusters();
|
||||
// Only update state if component is still mounted
|
||||
if (isMounted.current) {
|
||||
setClusters(data);
|
||||
succeeded = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted.current) {
|
||||
toastError(formatApiError(err) || ClusterErrors.LOAD_FAILED);
|
||||
console.error(err);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
if (isRefresh) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isMounted = { current: true };
|
||||
loadClusters(isMounted);
|
||||
|
||||
// Cleanup: mark component as unmounted
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Refresh clusters
|
||||
const handleRefresh = async () => {
|
||||
toastInfo("Refreshing clusters...", {
|
||||
title: "Cluster Refresh",
|
||||
durationMs: 1800,
|
||||
mergeKey: "clusters-refresh",
|
||||
});
|
||||
const refreshed = await loadClusters({ current: true }, true);
|
||||
if (refreshed) {
|
||||
success(SuccessMessages.DATA_REFRESHED);
|
||||
}
|
||||
};
|
||||
|
||||
// Add cluster
|
||||
const handleAdd = () => {
|
||||
setEditingCluster(undefined);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Edit cluster
|
||||
const handleEdit = (cluster: ClusterResponse) => {
|
||||
setEditingCluster(cluster);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Save cluster
|
||||
const handleSave = async (
|
||||
data: Omit<ClusterResponse, "id" | "createdAt" | "updatedAt">
|
||||
) => {
|
||||
try {
|
||||
const actionLabel = editingCluster ? "Updating cluster..." : "Creating cluster...";
|
||||
toastInfo(actionLabel, {
|
||||
title: editingCluster ? "Update Cluster" : "Create Cluster",
|
||||
durationMs: 1800,
|
||||
mergeKey: editingCluster ? `cluster-save-${editingCluster.id}` : "cluster-create",
|
||||
});
|
||||
|
||||
if (editingCluster) {
|
||||
if (!editingCluster.id) {
|
||||
toastError("Cluster identifier is missing. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
await updateCluster({ clusterId: editingCluster.id }, data);
|
||||
success(SuccessMessages.CLUSTER_UPDATED);
|
||||
} else {
|
||||
// Build create data with only non-empty auth fields
|
||||
const createData: any = {
|
||||
name: data.name,
|
||||
host: data.host,
|
||||
description: data.description,
|
||||
};
|
||||
|
||||
// Add certificate auth if all three fields are provided
|
||||
if (data.caData && data.certData && data.keyData) {
|
||||
createData.caData = data.caData;
|
||||
createData.certData = data.certData;
|
||||
createData.keyData = data.keyData;
|
||||
}
|
||||
|
||||
// Add token auth if provided
|
||||
if (data.token) {
|
||||
createData.token = data.token;
|
||||
}
|
||||
|
||||
await createCluster(createData);
|
||||
success(SuccessMessages.CLUSTER_CREATED);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingCluster(undefined);
|
||||
loadClusters({ current: true });
|
||||
} catch (error) {
|
||||
const errorMsg = editingCluster ? ClusterErrors.UPDATE_FAILED : ClusterErrors.CREATE_FAILED;
|
||||
toastError(formatApiError(error) || errorMsg);
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete cluster
|
||||
const handleDelete = async (cluster: ClusterResponse) => {
|
||||
if (!confirm(`Are you sure you want to delete cluster "${cluster.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toastInfo(`Deleting cluster "${cluster.name}"...`, {
|
||||
title: "Delete Cluster",
|
||||
durationMs: 1800,
|
||||
mergeKey: `cluster-delete-${cluster.id}`,
|
||||
});
|
||||
if (!cluster.id) {
|
||||
toastError("Cluster identifier is missing. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
await deleteCluster({ clusterId: cluster.id });
|
||||
success(SuccessMessages.CLUSTER_DELETED);
|
||||
loadClusters({ current: true });
|
||||
} catch (error) {
|
||||
toastError(formatApiError(error) || ClusterErrors.DELETE_FAILED);
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Configuration - Clusters"
|
||||
description="Manage Kubernetes cluster connections and authentication"
|
||||
icon={Server}
|
||||
iconColor="text-blue-400"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={RefreshCw}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
spinIcon={true}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={Plus}
|
||||
onClick={handleAdd}
|
||||
className="bg-blue-600 hover:bg-blue-700 border-blue-600"
|
||||
>
|
||||
Add Cluster
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Cluster List */}
|
||||
<ClusterList
|
||||
clusters={clusters}
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 p-4 bg-blue-900/20 border border-blue-700/50 rounded-lg">
|
||||
<h3 className="text-sm font-semibold text-blue-300 mb-2">💡 Usage Tips</h3>
|
||||
<ul className="text-sm text-gray-400 space-y-1">
|
||||
<li>• Cluster configuration contains all information needed to connect to a Kubernetes cluster</li>
|
||||
<li>• <strong>Cluster Address</strong>: URL of the Kubernetes API Server</li>
|
||||
<li>• <strong>Certificate Format</strong>: All certificate fields use base64 encoded strings (same as kubeconfig)</li>
|
||||
<li>• <strong>Backend Processing</strong>: The backend automatically handles certificate format conversion</li>
|
||||
<li>• You can select clusters for application deployment on the instances page</li>
|
||||
<li>• Recommended to configure separate clusters for different environments (development, testing, production)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal
|
||||
open={showModal}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingCluster(undefined);
|
||||
}}
|
||||
title={editingCluster ? "Edit Cluster Configuration" : "Add Cluster Configuration"}
|
||||
>
|
||||
<ClusterForm
|
||||
cluster={editingCluster}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setShowModal(false);
|
||||
setEditingCluster(undefined);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClusterConfigPage;
|
||||
15
frontend/src/features/configuration/index.ts
Normal file
15
frontend/src/features/configuration/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Configuration Module
|
||||
* 配置模块 - 集群和仓库配置
|
||||
*/
|
||||
|
||||
// Clusters
|
||||
export { default as ClusterConfigPage } from './clusters/pages/ClusterConfigPage';
|
||||
export * from './clusters/components/ClusterList';
|
||||
export * from './clusters/components/ClusterForm';
|
||||
|
||||
// Registries
|
||||
export { default as RegistryConfigPage } from './registries/pages/RegistryConfigPage';
|
||||
export * from './registries/components/RegistryList';
|
||||
export * from './registries/components/RegistryForm';
|
||||
|
||||
@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Registry Configuration Form
|
||||
* For adding and editing registry configurations
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Save, X, TestTube } from "lucide-react";
|
||||
import type { RegistryResponse } from "@/core/types";
|
||||
import { checkRegistryHealth } from "@/api";
|
||||
import { useToast } from "@/shared";
|
||||
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
import { ButtonText, LabelText } from "@/shared/constants";
|
||||
|
||||
interface RegistryFormProps {
|
||||
registry?: RegistryResponse;
|
||||
onSave: (data: Omit<RegistryResponse, "id">) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
registry,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { success, error: toastError, info: toastInfo } = useToast();
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<
|
||||
Omit<RegistryResponse, "id" | "createdAt" | "updatedAt">
|
||||
>({
|
||||
name: registry?.name || "",
|
||||
url: registry?.url || "",
|
||||
description: registry?.description || "",
|
||||
username: registry?.username || "",
|
||||
password: registry?.password || "",
|
||||
hasPassword: registry?.hasPassword || false,
|
||||
insecure: registry?.insecure || false,
|
||||
});
|
||||
|
||||
// 新密码输入(编辑模式)
|
||||
const [newPassword, setNewPassword] = useState<string>("");
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
const checked = type === "checkbox" ? (e.target as HTMLInputElement).checked : undefined;
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 准备提交数据
|
||||
const submitData = { ...formData };
|
||||
|
||||
// 编辑模式:如果用户输入了新密码,使用新密码;否则不发送密码字段
|
||||
if (registry) {
|
||||
if (newPassword) {
|
||||
submitData.password = newPassword;
|
||||
} else {
|
||||
// 不发送密码字段,保持后端原有密码
|
||||
delete (submitData as any).password;
|
||||
}
|
||||
}
|
||||
|
||||
onSave(submitData);
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!registry?.id) {
|
||||
toastError("Please save the registry configuration before testing the connection.");
|
||||
return;
|
||||
}
|
||||
|
||||
toastInfo("Testing registry connection...", {
|
||||
title: "Connection Test",
|
||||
durationMs: 1800,
|
||||
mergeKey: `registry-test-${registry.id}`,
|
||||
});
|
||||
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await checkRegistryHealth({ registryId: registry.id }) as any;
|
||||
// ✅ FIX: Backend returns { healthy: boolean, message: string }
|
||||
if (result.healthy === true) {
|
||||
success(result.message || SuccessMessages.REGISTRY_CONNECTION_OK);
|
||||
} else {
|
||||
const errorMsg = result.message || RegistryErrors.CONNECTION_TEST_FAILED;
|
||||
toastError(errorMsg);
|
||||
}
|
||||
} catch (error) {
|
||||
toastError(formatApiError(error) || RegistryErrors.CONNECTION_TEST_FAILED);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
{LabelText.NAME} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="e.g., Harbor Production"
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Registry URL <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="url"
|
||||
value={formData.url}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="https://registry.example.com"
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
OCI registry access URL (e.g., https://registry.hub.docker.com)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
{LabelText.USERNAME} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Registry username"
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
{LabelText.PASSWORD} {!registry && <span className="text-red-400">*</span>}
|
||||
</label>
|
||||
{registry ? (
|
||||
// 编辑模式:显示状态和新密码输入
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg">
|
||||
<span className="text-gray-400 text-sm">当前密码:</span>
|
||||
<span className="text-white font-mono">{formData.password}</span>
|
||||
{formData.hasPassword && (
|
||||
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full"></span>
|
||||
已设置(加密存储)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="输入新密码以覆盖(留空保持不变)"
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
💡 输入新密码以覆盖原密码,留空则保持原密码不变
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// 创建模式:必填
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Registry password or access token"
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
{LabelText.DESCRIPTION}
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
placeholder="Registry purpose or description"
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Insecure */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="insecure"
|
||||
name="insecure"
|
||||
checked={formData.insecure}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 rounded focus:ring-purple-500 focus:ring-2"
|
||||
/>
|
||||
<label htmlFor="insecure" className="ml-2 text-sm text-gray-300">
|
||||
Allow insecure connection (skip SSL certificate verification)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{ButtonText.SAVE}
|
||||
</button>
|
||||
{registry?.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<TestTube className={`w-4 h-4 ${testing ? "animate-pulse" : ""}`} />
|
||||
{ButtonText.TEST_CONNECTION}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
{ButtonText.CANCEL}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Registry Configuration List
|
||||
* Display all registry configurations
|
||||
*/
|
||||
import React from "react";
|
||||
import { Edit2, Trash2, Database, ExternalLink } from "lucide-react";
|
||||
import type { AppRegistry } from "@/core/types";
|
||||
import { EmptyStateSimple } from "@/shared/components";
|
||||
import { LoadingText, EmptyText } from "@/shared/constants";
|
||||
|
||||
interface RegistryListProps {
|
||||
registries: AppRegistry[];
|
||||
loading: boolean;
|
||||
onEdit: (registry: AppRegistry) => void;
|
||||
onDelete: (registry: AppRegistry) => void;
|
||||
}
|
||||
|
||||
export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
registries,
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
{LoadingText.LOADING_REGISTRIES}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (registries.length === 0) {
|
||||
return (
|
||||
<EmptyStateSimple
|
||||
title={EmptyText.NO_REGISTRIES}
|
||||
description={EmptyText.NO_REGISTRIES_DESC}
|
||||
Icon={Database}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{registries.map((registry) => (
|
||||
<div
|
||||
key={registry.id}
|
||||
className="p-4 bg-gray-800 border border-gray-700 rounded-lg hover:border-gray-600 transition"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Left: Basic Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Database className="w-5 h-5 text-purple-400 flex-shrink-0" />
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{registry.name}
|
||||
</h3>
|
||||
{registry.insecure && (
|
||||
<span className="px-2 py-0.5 bg-yellow-900/30 text-yellow-400 text-xs rounded">
|
||||
Insecure
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-8 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<a
|
||||
href={registry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-purple-400 transition"
|
||||
>
|
||||
{registry.url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{registry.description && (
|
||||
<p className="text-sm text-gray-500">{registry.description}</p>
|
||||
)}
|
||||
|
||||
{registry.username && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Username: <span className="text-gray-400">{registry.username}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Action Buttons */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => onEdit(registry)}
|
||||
className="p-2 text-gray-400 hover:text-blue-400 hover:bg-gray-700 rounded-lg transition"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(registry)}
|
||||
className="p-2 text-gray-400 hover:text-red-400 hover:bg-gray-700 rounded-lg transition"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
frontend/src/features/configuration/registries/index.ts
Normal file
12
frontend/src/features/configuration/registries/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Registry Management Feature
|
||||
* OCI 仓库配置管理功能
|
||||
*/
|
||||
|
||||
// Export pages
|
||||
export { default as RegistryConfigPage } from './pages/RegistryConfigPage';
|
||||
|
||||
// Export components
|
||||
export { RegistryForm } from './components/RegistryForm';
|
||||
export { RegistryList } from './components/RegistryList';
|
||||
|
||||
@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Registry Configuration Page
|
||||
* Manage OCI Registry configurations
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, RefreshCw, Database } from "lucide-react";
|
||||
import { useToast } from "@/shared";
|
||||
import { Modal, PageHeader, Button } from "@/shared/components";
|
||||
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
import { RegistryForm } from "../components/RegistryForm";
|
||||
import { RegistryList } from "../components/RegistryList";
|
||||
import {
|
||||
listRegistries,
|
||||
createRegistry,
|
||||
updateRegistry,
|
||||
deleteRegistry,
|
||||
} from "@/api";
|
||||
import type { CreateRegistryRequest } from "@/api";
|
||||
import type { AppRegistry, RegistryResponse } from "@/core/types";
|
||||
|
||||
const RegistryConfigPage: React.FC = () => {
|
||||
const { success, error: toastError, info: toastInfo } = useToast();
|
||||
|
||||
const [registries, setRegistries] = useState<AppRegistry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRegistry, setEditingRegistry] = useState<AppRegistry | undefined>(undefined);
|
||||
|
||||
// Load registries
|
||||
const loadRegistries = async (isMounted = { current: true }, isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
let succeeded = false;
|
||||
try {
|
||||
const data = await listRegistries();
|
||||
// Only update state if component is still mounted
|
||||
if (isMounted.current) {
|
||||
setRegistries(data);
|
||||
succeeded = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted.current) {
|
||||
toastError(formatApiError(err) || RegistryErrors.LOAD_FAILED);
|
||||
console.error(err);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
if (isRefresh) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isMounted = { current: true };
|
||||
loadRegistries(isMounted);
|
||||
|
||||
// Cleanup: mark component as unmounted
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Refresh registries
|
||||
const handleRefresh = async () => {
|
||||
toastInfo("Refreshing registries...", {
|
||||
title: "Registry Refresh",
|
||||
durationMs: 1800,
|
||||
mergeKey: "registries-refresh",
|
||||
});
|
||||
const refreshed = await loadRegistries({ current: true }, true);
|
||||
if (refreshed) {
|
||||
success(SuccessMessages.DATA_REFRESHED);
|
||||
}
|
||||
};
|
||||
|
||||
// Add registry
|
||||
const handleAdd = () => {
|
||||
setEditingRegistry(undefined);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Edit registry
|
||||
const handleEdit = (registry: RegistryResponse) => {
|
||||
setEditingRegistry(registry);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Save registry
|
||||
const handleSave = async (data: Omit<RegistryResponse, "id">) => {
|
||||
try {
|
||||
const actionLabel = editingRegistry ? "Updating registry..." : "Creating registry...";
|
||||
toastInfo(actionLabel, {
|
||||
title: editingRegistry ? "Update Registry" : "Create Registry",
|
||||
durationMs: 1800,
|
||||
mergeKey: editingRegistry ? `registry-save-${editingRegistry.id}` : "registry-create",
|
||||
});
|
||||
|
||||
if (editingRegistry) {
|
||||
if (!editingRegistry.id) {
|
||||
toastError("Registry identifier is missing. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
await updateRegistry({ registryId: editingRegistry.id }, data);
|
||||
success(SuccessMessages.REGISTRY_UPDATED);
|
||||
} else {
|
||||
if (!data.name || !data.url || !data.username || !data.password) {
|
||||
toastError("Name, URL, username, and password are required to create a registry.");
|
||||
return;
|
||||
}
|
||||
const createData: CreateRegistryRequest = {
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
description: data.description || "",
|
||||
insecure: data.insecure ?? false,
|
||||
};
|
||||
const newRegistry = await createRegistry(createData);
|
||||
success(SuccessMessages.REGISTRY_CREATED);
|
||||
console.log("New registry added:", newRegistry);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingRegistry(undefined);
|
||||
await loadRegistries({ current: true });
|
||||
|
||||
// Notify other pages to refresh (if registry browser is open)
|
||||
localStorage.setItem('registry_updated', Date.now().toString());
|
||||
localStorage.removeItem('registry_updated');
|
||||
} catch (error) {
|
||||
const errorMsg = editingRegistry ? RegistryErrors.UPDATE_FAILED : RegistryErrors.CREATE_FAILED;
|
||||
toastError(formatApiError(error) || errorMsg);
|
||||
console.error("Registry save error:", error);
|
||||
|
||||
// Don't close modal so user can retry
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete registry
|
||||
const handleDelete = async (registry: RegistryResponse) => {
|
||||
if (!confirm(`Are you sure you want to delete registry "${registry.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toastInfo(`Deleting registry "${registry.name}"...`, {
|
||||
title: "Delete Registry",
|
||||
durationMs: 1800,
|
||||
mergeKey: `registry-delete-${registry.id}`,
|
||||
});
|
||||
if (!registry.id) {
|
||||
toastError("Registry identifier is missing. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
await deleteRegistry({ registryId: registry.id });
|
||||
success(SuccessMessages.REGISTRY_DELETED);
|
||||
loadRegistries({ current: true });
|
||||
} catch (error) {
|
||||
toastError(formatApiError(error) || RegistryErrors.DELETE_FAILED);
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Configuration - Registries"
|
||||
description="Manage OCI Registry connections and authentication"
|
||||
icon={Database}
|
||||
iconColor="text-purple-400"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={RefreshCw}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
spinIcon={true}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={Plus}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Add Registry
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Registry List */}
|
||||
<RegistryList
|
||||
registries={registries}
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 p-4 bg-purple-900/20 border border-purple-700/50 rounded-lg">
|
||||
<h3 className="text-sm font-semibold text-purple-300 mb-2">💡 Usage Tips</h3>
|
||||
<ul className="text-sm text-gray-400 space-y-1">
|
||||
<li>• Registry configuration is used to connect to OCI-compliant container registries (Docker Hub, Harbor, GitHub Container Registry, etc.)</li>
|
||||
<li>• <strong>Registry URL</strong>: The access address of the container registry (e.g., https://registry.hub.docker.com)</li>
|
||||
<li>• <strong>Authentication</strong>: Username and password for accessing private registries</li>
|
||||
<li>• <strong>OCI Standard</strong>: Fully compatible with OCI Distribution Specification v2</li>
|
||||
<li>• The system automatically accesses repositories and retrieves image lists via OCI v2 API</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal
|
||||
open={showModal}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingRegistry(undefined);
|
||||
}}
|
||||
title={editingRegistry ? "Edit Registry Configuration" : "Add Registry Configuration"}
|
||||
icon={Database}
|
||||
size="lg"
|
||||
>
|
||||
<RegistryForm
|
||||
registry={editingRegistry}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setShowModal(false);
|
||||
setEditingRegistry(undefined);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistryConfigPage;
|
||||
Reference in New Issue
Block a user