refactor: full-stack restructure with multi-tenancy, workspace management, and K8s diagnostics
- Add Workspace domain (entity, repository, service, handler, DTO) - Add multi-tenant K8s client with tenant binding and quota management - Add K8s diagnostics client (instance diagnostics) - Add authorization middleware (authz package) - Restructure frontend to feature-based architecture (features/) - Add User Management page in configuration - Add AccessDenied page and route guards - Refactor shared components (form inputs, layout, UI) - Update Tailwind config for new design system - Add comprehensive documentation (docs/, tasks/, plans) - Improve cluster service with better kubeconfig handling - Add tests for crypto, config, helm client, tenant binding
This commit is contained in:
@ -78,15 +78,21 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
newErrors.host = ValidationErrors.INVALID_URL;
|
||||
}
|
||||
|
||||
// 创建模式:必填证书
|
||||
// 创建模式:需要 token 或完整证书三件套
|
||||
if (!cluster) {
|
||||
if (!formData.caData.trim()) {
|
||||
const hasToken = Boolean(formData.token.trim());
|
||||
const hasCertAuth = Boolean(
|
||||
formData.caData.trim() &&
|
||||
formData.certData.trim() &&
|
||||
formData.keyData.trim()
|
||||
);
|
||||
if (!hasToken && !hasCertAuth && !formData.caData.trim()) {
|
||||
newErrors.caData = ValidationErrors.REQUIRED_FIELD("CA Certificate");
|
||||
}
|
||||
if (!formData.certData.trim()) {
|
||||
if (!hasToken && !hasCertAuth && !formData.certData.trim()) {
|
||||
newErrors.certData = ValidationErrors.REQUIRED_FIELD("Client Certificate");
|
||||
}
|
||||
if (!formData.keyData.trim()) {
|
||||
if (!hasToken && !hasCertAuth && !formData.keyData.trim()) {
|
||||
newErrors.keyData = ValidationErrors.REQUIRED_FIELD("Client Key");
|
||||
}
|
||||
}
|
||||
@ -115,6 +121,9 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
if (newCaData.trim()) submitData.caData = newCaData.trim();
|
||||
if (newCertData.trim()) submitData.certData = newCertData.trim();
|
||||
if (newKeyData.trim()) submitData.keyData = newKeyData.trim();
|
||||
if (formData.token.trim() && formData.token.trim() !== (cluster.token ?? "")) {
|
||||
submitData.token = formData.token.trim();
|
||||
}
|
||||
} else {
|
||||
// 创建模式:发送所有必填字段(使用 camelCase)
|
||||
submitData.caData = formData.caData.trim();
|
||||
@ -131,7 +140,7 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
<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">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
@ -139,9 +148,9 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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`}
|
||||
className={`w-full bg-white border ${
|
||||
errors.name ? "border-red-500" : "border-slate-300"
|
||||
} rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-slate-900 placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none`}
|
||||
placeholder="e.g., Production Cluster"
|
||||
/>
|
||||
{errors.name && (
|
||||
@ -151,7 +160,7 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
|
||||
{/* API Server URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
@ -159,31 +168,31 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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`}
|
||||
className={`w-full bg-white border ${
|
||||
errors.host ? "border-red-500" : "border-slate-300"
|
||||
} rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-slate-900 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">
|
||||
<p className="mt-1 text-xs text-slate-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">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg">
|
||||
<span className="text-slate-500 text-sm">当前:</span>
|
||||
<span className="text-slate-900 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>
|
||||
@ -195,10 +204,10 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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"
|
||||
className="w-full bg-white border border-slate-300 rounded-lg p-2.5 sm:p-3 text-slate-900 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 className="mt-1 text-xs text-slate-500">
|
||||
💡 输入新证书以覆盖,留空则保持原证书不变
|
||||
</p>
|
||||
</>
|
||||
@ -209,15 +218,15 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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`}
|
||||
className={`w-full bg-white border ${
|
||||
errors.caData ? "border-red-500" : "border-slate-300"
|
||||
} rounded-lg p-2.5 sm:p-3 text-slate-900 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">
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Cluster CA certificate in base64 format (certificate-authority-data)
|
||||
</p>
|
||||
</>
|
||||
@ -226,16 +235,16 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
|
||||
{/* Client Certificate */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg">
|
||||
<span className="text-slate-500 text-sm">当前:</span>
|
||||
<span className="text-slate-900 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>
|
||||
@ -247,10 +256,10 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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"
|
||||
className="w-full bg-white border border-slate-300 rounded-lg p-2.5 sm:p-3 text-slate-900 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 className="mt-1 text-xs text-slate-500">
|
||||
💡 输入新证书以覆盖,留空则保持原证书不变
|
||||
</p>
|
||||
</>
|
||||
@ -260,15 +269,15 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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`}
|
||||
className={`w-full bg-white border ${
|
||||
errors.certData ? "border-red-500" : "border-slate-300"
|
||||
} rounded-lg p-2.5 sm:p-3 text-slate-900 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">
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Client certificate in base64 format (client-certificate-data)
|
||||
</p>
|
||||
</>
|
||||
@ -277,16 +286,16 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
|
||||
{/* Client Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg">
|
||||
<span className="text-slate-500 text-sm">当前:</span>
|
||||
<span className="text-slate-900 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>
|
||||
@ -298,10 +307,10 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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"
|
||||
className="w-full bg-white border border-slate-300 rounded-lg p-2.5 sm:p-3 text-slate-900 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 className="mt-1 text-xs text-slate-500">
|
||||
💡 输入新密钥以覆盖,留空则保持原密钥不变
|
||||
</p>
|
||||
</>
|
||||
@ -311,42 +320,57 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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`}
|
||||
className={`w-full bg-white border ${
|
||||
errors.keyData ? "border-red-500" : "border-slate-300"
|
||||
} rounded-lg p-2.5 sm:p-3 text-slate-900 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">
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Client private key in base64 format (client-key-data)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bearer Token */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5 flex items-center gap-2">
|
||||
<Key className="w-4 h-4 text-blue-600" />
|
||||
Bearer Token {!cluster && <span className="text-slate-400 text-xs">(optional alternative to client certs)</span>}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.token}
|
||||
onChange={(e) => handleChange("token", e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-white border border-slate-300 rounded-lg p-2.5 sm:p-3 text-slate-900 placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none"
|
||||
placeholder="Paste service-account bearer token"
|
||||
/>
|
||||
</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" />
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-slate-500" />
|
||||
{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"
|
||||
className="w-full bg-white border border-slate-300 rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-slate-900 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">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 pt-3 sm:pt-4 border-t border-slate-200">
|
||||
<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"
|
||||
className="flex-1 px-4 py-2.5 bg-white hover:bg-slate-100 text-slate-900 rounded-lg transition text-sm sm:text-base"
|
||||
>
|
||||
{ButtonText.CANCEL}
|
||||
</button>
|
||||
|
||||
@ -3,15 +3,19 @@
|
||||
* Display cluster list with edit and delete actions
|
||||
*/
|
||||
import React from "react";
|
||||
import { Server, Edit, Trash2, Key, ExternalLink } from "lucide-react";
|
||||
import { Server, Edit, Trash2, Key, ExternalLink, Activity } from "lucide-react";
|
||||
import type { ClusterConfig } from "@/core/types";
|
||||
import { LoadingText, EmptyText } from "@/shared/constants";
|
||||
import type { User } from "@/app/providers/AuthContext";
|
||||
import { canUseResourceAction, getVisibilityLabel, type ResourceWithAccess } from "@/app/providers/auth-model";
|
||||
|
||||
interface ClusterListProps {
|
||||
clusters: ClusterConfig[];
|
||||
loading: boolean;
|
||||
onEdit: (cluster: ClusterConfig) => void;
|
||||
onDelete: (cluster: ClusterConfig) => void;
|
||||
onHealthCheck?: (cluster: ClusterConfig) => void;
|
||||
user?: User | null;
|
||||
}
|
||||
|
||||
export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
@ -19,12 +23,14 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onHealthCheck,
|
||||
user,
|
||||
}) => {
|
||||
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>
|
||||
<p className="text-slate-500 mt-4">{LoadingText.LOADING_CLUSTERS}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -32,43 +38,63 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
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>
|
||||
<Server className="w-16 h-16 text-slate-400 mx-auto mb-4" />
|
||||
<p className="text-slate-500 text-lg mb-2">{EmptyText.NO_CLUSTERS}</p>
|
||||
<p className="text-slate-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"
|
||||
>
|
||||
{clusters.map((cluster) => {
|
||||
const access = cluster as ClusterConfig & ResourceWithAccess;
|
||||
const canEdit = canUseResourceAction(access, "update", user);
|
||||
const canDelete = canUseResourceAction(access, "delete", user);
|
||||
const canTest = canUseResourceAction(access, "test", user);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cluster.id}
|
||||
className="bg-white border border-slate-200 rounded-lg p-5 hover:bg-slate-50 hover:border-slate-300 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">
|
||||
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2 mb-1">
|
||||
<Server className="w-5 h-5 text-blue-400" />
|
||||
{cluster.name}
|
||||
<span className="rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[11px] font-medium text-slate-500">
|
||||
{getVisibilityLabel(access.visibility)}
|
||||
</span>
|
||||
</h3>
|
||||
{cluster.description && (
|
||||
<p className="text-sm text-gray-400">{cluster.description}</p>
|
||||
<p className="text-sm text-slate-500">{cluster.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{onHealthCheck && canTest && (
|
||||
<button
|
||||
onClick={() => onHealthCheck(cluster)}
|
||||
className="p-2 text-slate-500 hover:text-emerald-700 hover:bg-emerald-50 rounded-lg transition"
|
||||
title="Test Connection"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onEdit(cluster)}
|
||||
className="p-2 text-gray-400 hover:text-blue-400 hover:bg-blue-400/10 rounded-lg transition"
|
||||
title="Edit"
|
||||
disabled={!canEdit}
|
||||
className="p-2 text-slate-500 hover:text-blue-400 hover:bg-blue-50 rounded-lg transition"
|
||||
title={canEdit ? "Edit" : "Read-only"}
|
||||
>
|
||||
<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"
|
||||
disabled={!canDelete}
|
||||
className="p-2 text-slate-500 hover:text-red-400 hover:bg-red-50 rounded-lg transition disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-500"
|
||||
title={canDelete ? "Delete" : "Read-only"}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@ -80,8 +106,8 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
<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}>
|
||||
<p className="text-xs text-slate-500 mb-0.5">API Server URL</p>
|
||||
<p className="text-sm text-slate-700 font-mono truncate" title={cluster.host}>
|
||||
{cluster.host}
|
||||
</p>
|
||||
</div>
|
||||
@ -91,8 +117,8 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
<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"}`}>
|
||||
<p className="text-xs text-slate-500 mb-0.5">CA Certificate</p>
|
||||
<p className={`text-xs ${cluster.hasCaData ? "text-green-400" : "text-slate-500"}`}>
|
||||
{cluster.hasCaData ? "✓ Configured" : "✗ Not Configured"}
|
||||
</p>
|
||||
</div>
|
||||
@ -101,8 +127,8 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
<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"}`}>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Client Cert</p>
|
||||
<p className={`text-xs ${cluster.hasCertData ? "text-yellow-400" : "text-slate-500"}`}>
|
||||
{cluster.hasCertData ? "✓ Configured" : "✗ Not Configured"}
|
||||
</p>
|
||||
</div>
|
||||
@ -111,8 +137,8 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
<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"}`}>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Client Key</p>
|
||||
<p className={`text-xs ${cluster.hasKeyData ? "text-red-400" : "text-slate-500"}`}>
|
||||
{cluster.hasKeyData ? "✓ Configured" : "✗ Not Configured"}
|
||||
</p>
|
||||
</div>
|
||||
@ -122,14 +148,15 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
|
||||
{/* Footer */}
|
||||
{cluster.createdAt && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-700/50">
|
||||
<p className="text-xs text-gray-500">
|
||||
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500">
|
||||
Created: {new Date(cluster.createdAt).toLocaleString("en-US")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, RefreshCw, Server } from "lucide-react";
|
||||
import { useToast, Modal, Button, PageHeader } from "@/shared";
|
||||
import { useAuth } from "@/app/providers";
|
||||
import { isAdminUser } from "@/app/providers/auth-model";
|
||||
import { ClusterErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
import { ClusterForm } from "../components/ClusterForm";
|
||||
import { ClusterList } from "../components/ClusterList";
|
||||
@ -13,11 +15,14 @@ import {
|
||||
createCluster,
|
||||
updateCluster,
|
||||
deleteCluster,
|
||||
getClusterHealth,
|
||||
} from "@/api";
|
||||
import type { ClusterConfig, ClusterResponse } from "@/core/types";
|
||||
|
||||
const ClusterConfigPage: React.FC = () => {
|
||||
const { success, error: toastError, info: toastInfo } = useToast();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = isAdminUser(user);
|
||||
|
||||
const [clusters, setClusters] = useState<ClusterConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -170,12 +175,40 @@ const ClusterConfigPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleHealthCheck = async (cluster: ClusterResponse) => {
|
||||
if (!cluster.id) {
|
||||
toastError("Cluster identifier is missing. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
toastInfo(`Testing cluster "${cluster.name}"...`, {
|
||||
title: "Cluster Health",
|
||||
durationMs: 1800,
|
||||
mergeKey: `cluster-health-${cluster.id}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await getClusterHealth({ clusterId: cluster.id }) as any;
|
||||
if (result.healthy === false) {
|
||||
toastError(result.message || ClusterErrors.CONNECTION_FAILED);
|
||||
return;
|
||||
}
|
||||
success(result.message || SuccessMessages.OPERATION_COMPLETED);
|
||||
} catch (error) {
|
||||
toastError(formatApiError(error) || ClusterErrors.CONNECTION_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Configuration - Clusters"
|
||||
description="Manage Kubernetes cluster connections and authentication"
|
||||
description={
|
||||
isAdmin
|
||||
? "Manage all Kubernetes cluster connections and authentication"
|
||||
: "Manage your private Kubernetes cluster connections"
|
||||
}
|
||||
icon={Server}
|
||||
iconColor="text-blue-400"
|
||||
actions={
|
||||
@ -207,21 +240,10 @@ const ClusterConfigPage: React.FC = () => {
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onHealthCheck={handleHealthCheck}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
|
||||
@ -104,7 +104,7 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{LabelText.NAME} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -114,13 +114,13 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Registry URL <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -130,16 +130,16 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-slate-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">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{LabelText.USERNAME} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -149,21 +149,21 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg">
|
||||
<span className="text-slate-500 text-sm">当前密码:</span>
|
||||
<span className="text-slate-900 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>
|
||||
@ -176,9 +176,9 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
💡 输入新密码以覆盖原密码,留空则保持原密码不变
|
||||
</p>
|
||||
</>
|
||||
@ -191,14 +191,14 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{LabelText.DESCRIPTION}
|
||||
</label>
|
||||
<textarea
|
||||
@ -207,7 +207,7 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -219,9 +219,9 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-4 h-4 text-purple-600 bg-white border-slate-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<label htmlFor="insecure" className="ml-2 text-sm text-gray-300">
|
||||
<label htmlFor="insecure" className="ml-2 text-sm text-slate-700">
|
||||
Allow insecure connection (skip SSL certificate verification)
|
||||
</label>
|
||||
</div>
|
||||
@ -230,7 +230,7 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
<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"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{ButtonText.SAVE}
|
||||
@ -240,7 +240,7 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="px-4 py-2 bg-white hover:bg-slate-100 text-slate-900 rounded-lg transition flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<TestTube className={`w-4 h-4 ${testing ? "animate-pulse" : ""}`} />
|
||||
{ButtonText.TEST_CONNECTION}
|
||||
@ -249,7 +249,7 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
<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"
|
||||
className="px-4 py-2 bg-white hover:bg-slate-100 text-slate-900 rounded-lg transition flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
{ButtonText.CANCEL}
|
||||
|
||||
@ -7,12 +7,15 @@ 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";
|
||||
import type { User } from "@/app/providers/AuthContext";
|
||||
import { canUseResourceAction, getVisibilityLabel, type ResourceWithAccess } from "@/app/providers/auth-model";
|
||||
|
||||
interface RegistryListProps {
|
||||
registries: AppRegistry[];
|
||||
loading: boolean;
|
||||
onEdit: (registry: AppRegistry) => void;
|
||||
onDelete: (registry: AppRegistry) => void;
|
||||
user?: User | null;
|
||||
}
|
||||
|
||||
export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
@ -20,10 +23,11 @@ export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
user,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
{LoadingText.LOADING_REGISTRIES}
|
||||
</div>
|
||||
);
|
||||
@ -41,19 +45,27 @@ export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
|
||||
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"
|
||||
>
|
||||
{registries.map((registry) => {
|
||||
const access = registry as AppRegistry & ResourceWithAccess;
|
||||
const canEdit = canUseResourceAction(access, "update", user);
|
||||
const canDelete = canUseResourceAction(access, "delete", user);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={registry.id}
|
||||
className="p-4 bg-slate-50 border border-slate-200 rounded-lg hover:border-slate-300 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">
|
||||
<Database className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{registry.name}
|
||||
</h3>
|
||||
<span className="rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px] font-medium text-slate-500">
|
||||
{getVisibilityLabel(access.visibility)}
|
||||
</span>
|
||||
{registry.insecure && (
|
||||
<span className="px-2 py-0.5 bg-yellow-900/30 text-yellow-400 text-xs rounded">
|
||||
Insecure
|
||||
@ -62,25 +74,25 @@ export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="ml-8 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<a
|
||||
href={registry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-purple-400 transition"
|
||||
className="hover:text-blue-600 transition"
|
||||
>
|
||||
{registry.url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{registry.description && (
|
||||
<p className="text-sm text-gray-500">{registry.description}</p>
|
||||
<p className="text-sm text-slate-500">{registry.description}</p>
|
||||
)}
|
||||
|
||||
{registry.username && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Username: <span className="text-gray-400">{registry.username}</span>
|
||||
<p className="text-sm text-slate-500">
|
||||
Username: <span className="text-slate-500">{registry.username}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -90,22 +102,25 @@ export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
<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"
|
||||
disabled={!canEdit}
|
||||
className="p-2 text-slate-500 hover:text-blue-400 hover:bg-white rounded-lg transition disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-slate-500"
|
||||
title={canEdit ? "Edit" : "Read-only"}
|
||||
>
|
||||
<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"
|
||||
disabled={!canDelete}
|
||||
className="p-2 text-slate-500 hover:text-red-400 hover:bg-white rounded-lg transition disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-slate-500"
|
||||
title={canDelete ? "Delete" : "Read-only"}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, RefreshCw, Database } from "lucide-react";
|
||||
import { useToast } from "@/shared";
|
||||
import { useAuth } from "@/app/providers";
|
||||
import { isAdminUser } from "@/app/providers/auth-model";
|
||||
import { Modal, PageHeader, Button } from "@/shared/components";
|
||||
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
import { RegistryForm } from "../components/RegistryForm";
|
||||
@ -20,6 +22,8 @@ import type { AppRegistry, RegistryResponse } from "@/core/types";
|
||||
|
||||
const RegistryConfigPage: React.FC = () => {
|
||||
const { success, error: toastError, info: toastInfo } = useToast();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = isAdminUser(user);
|
||||
|
||||
const [registries, setRegistries] = useState<AppRegistry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -176,9 +180,13 @@ const RegistryConfigPage: React.FC = () => {
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Configuration - Registries"
|
||||
description="Manage OCI Registry connections and authentication"
|
||||
description={
|
||||
isAdmin
|
||||
? "Manage all OCI registry connections and authentication"
|
||||
: "Manage your private OCI registry connections"
|
||||
}
|
||||
icon={Database}
|
||||
iconColor="text-purple-400"
|
||||
iconColor="text-blue-600"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
@ -207,20 +215,9 @@ const RegistryConfigPage: React.FC = () => {
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
|
||||
@ -0,0 +1,502 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Gauge, KeyRound, Pencil, RefreshCw, Shield, Trash2, UserPlus, Users, X } from "lucide-react";
|
||||
import { createUser, listClusters, listUsers, updateUser, deleteUser, type ClusterResponse, type UserResponse } from "@/api";
|
||||
import { useToast } from "@/shared";
|
||||
import { Button, Input, Badge, LoadingState } from "@/shared/components";
|
||||
import { formatApiError } from "@/shared/utils";
|
||||
import { useAuth } from "@/app/providers";
|
||||
|
||||
const UserManagementPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { success, error: toastError } = useToast();
|
||||
const [users, setUsers] = useState<UserResponse[]>([]);
|
||||
const [clusters, setClusters] = useState<ClusterResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState("user");
|
||||
const [namespace, setNamespace] = useState("");
|
||||
const [defaultClusterId, setDefaultClusterId] = useState("");
|
||||
const [quotaCpu, setQuotaCpu] = useState("4");
|
||||
const [quotaMemory, setQuotaMemory] = useState("16Gi");
|
||||
const [quotaGpu, setQuotaGpu] = useState("0");
|
||||
const [quotaGpuMemory, setQuotaGpuMemory] = useState("0");
|
||||
const [mustChangePassword, setMustChangePassword] = useState(true);
|
||||
const [editingLimits, setEditingLimits] = useState<UserResponse | null>(null);
|
||||
const [editNamespace, setEditNamespace] = useState("");
|
||||
const [editDefaultClusterId, setEditDefaultClusterId] = useState("");
|
||||
const [editQuotaCpu, setEditQuotaCpu] = useState("");
|
||||
const [editQuotaMemory, setEditQuotaMemory] = useState("");
|
||||
const [editQuotaGpu, setEditQuotaGpu] = useState("");
|
||||
const [editQuotaGpuMemory, setEditQuotaGpuMemory] = useState("");
|
||||
const [savingLimits, setSavingLimits] = useState(false);
|
||||
|
||||
const sortedUsers = useMemo(
|
||||
() => [...users].sort((a, b) => (a.username ?? "").localeCompare(b.username ?? "")),
|
||||
[users]
|
||||
);
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setUsers(await listUsers());
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to load users");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadClusters = async () => {
|
||||
try {
|
||||
const data = await listClusters();
|
||||
const available = data.filter((cluster) => typeof cluster.id === "string" && cluster.id.length > 0);
|
||||
setClusters(available);
|
||||
setDefaultClusterId((current) => current || available[0]?.id || "");
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to load clusters");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
void loadClusters();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!username.trim() || !password.trim()) {
|
||||
toastError("Username and initial password are required.");
|
||||
return;
|
||||
}
|
||||
const effectiveNamespace = namespace.trim() || namespaceForUsername(username);
|
||||
setCreating(true);
|
||||
try {
|
||||
await createUser({
|
||||
username: username.trim(),
|
||||
password,
|
||||
role,
|
||||
...(role === "user"
|
||||
? {
|
||||
namespace: effectiveNamespace,
|
||||
defaultClusterId: defaultClusterId.trim(),
|
||||
quotaCpu: quotaCpu.trim(),
|
||||
quotaMemory: quotaMemory.trim(),
|
||||
quotaGpu: quotaGpu.trim(),
|
||||
quotaGpuMemory: quotaGpuMemory.trim(),
|
||||
}
|
||||
: {}),
|
||||
mustChangePassword,
|
||||
isActive: true,
|
||||
});
|
||||
success("User created");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setRole("user");
|
||||
setNamespace("");
|
||||
setDefaultClusterId(clusters[0]?.id || "");
|
||||
setQuotaCpu("4");
|
||||
setQuotaMemory("16Gi");
|
||||
setQuotaGpu("0");
|
||||
setQuotaGpuMemory("0");
|
||||
setMustChangePassword(true);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to create user");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (role !== "user") {
|
||||
return;
|
||||
}
|
||||
setNamespace((current) => {
|
||||
if (current.trim() && !current.startsWith("ocdp-u-")) {
|
||||
return current;
|
||||
}
|
||||
return namespaceForUsername(username);
|
||||
});
|
||||
}, [username, role]);
|
||||
|
||||
const toggleActive = async (target: UserResponse) => {
|
||||
if (!target.id) return;
|
||||
try {
|
||||
await updateUser(target.id, { isActive: !target.isActive });
|
||||
success(target.isActive ? "User disabled" : "User enabled");
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to update user");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRole = async (target: UserResponse) => {
|
||||
if (!target.id) return;
|
||||
const nextRole = target.role === "admin" ? "user" : "admin";
|
||||
try {
|
||||
await updateUser(target.id, { role: nextRole });
|
||||
success("User role updated");
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to update role");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (target: UserResponse) => {
|
||||
if (!target.id || target.id === user?.userId) return;
|
||||
if (!window.confirm(`Delete user ${target.username}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteUser(target.id);
|
||||
success("User deleted");
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to delete user");
|
||||
}
|
||||
};
|
||||
|
||||
const openLimitsEditor = (target: UserResponse) => {
|
||||
setEditingLimits(target);
|
||||
setEditNamespace(target.namespace || namespaceForUsername(target.username || ""));
|
||||
setEditDefaultClusterId(target.defaultClusterId || clusters[0]?.id || "");
|
||||
setEditQuotaCpu(target.quotaCpu || "4");
|
||||
setEditQuotaMemory(target.quotaMemory || "16Gi");
|
||||
setEditQuotaGpu(target.quotaGpu || "0");
|
||||
setEditQuotaGpuMemory(target.quotaGpuMemory || "0");
|
||||
};
|
||||
|
||||
const saveLimits = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!editingLimits?.id) return;
|
||||
setSavingLimits(true);
|
||||
try {
|
||||
await updateUser(editingLimits.id, {
|
||||
namespace: editNamespace.trim(),
|
||||
defaultClusterId: editDefaultClusterId.trim(),
|
||||
quotaCpu: editQuotaCpu.trim(),
|
||||
quotaMemory: editQuotaMemory.trim(),
|
||||
quotaGpu: editQuotaGpu.trim(),
|
||||
quotaGpuMemory: editQuotaGpuMemory.trim(),
|
||||
});
|
||||
success("User limits updated");
|
||||
setEditingLimits(null);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to update user limits");
|
||||
} finally {
|
||||
setSavingLimits(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-blue-700">
|
||||
<Shield className="h-4 w-4" />
|
||||
Admin only
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-slate-950">User Management</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Create accounts, assign roles, and disable access without public self-registration.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" icon={RefreshCw} onClick={loadUsers} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||
<form onSubmit={handleCreate} className="rounded-lg border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5 text-blue-600" />
|
||||
<h2 className="text-base font-semibold text-slate-900">Create User</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Username
|
||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" required />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Initial password
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Role
|
||||
<select
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
{role === "user" && (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<KeyRound className="h-4 w-4 text-blue-600" />
|
||||
Tenant namespace
|
||||
</div>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Namespace
|
||||
<Input
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
className="mt-1 font-mono"
|
||||
placeholder={namespaceForUsername(username)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block text-sm font-medium text-slate-700">
|
||||
Default cluster
|
||||
<select
|
||||
value={defaultClusterId}
|
||||
onChange={(event) => setDefaultClusterId(event.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="">Select a cluster</option>
|
||||
{clusters.map((cluster) => (
|
||||
<option key={cluster.id} value={cluster.id}>
|
||||
{cluster.name || cluster.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{role === "user" && (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Gauge className="h-4 w-4 text-blue-600" />
|
||||
Resource limits
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
CPU
|
||||
<Input value={quotaCpu} onChange={(e) => setQuotaCpu(e.target.value)} className="mt-1" placeholder="4" />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Memory
|
||||
<Input value={quotaMemory} onChange={(e) => setQuotaMemory(e.target.value)} className="mt-1" placeholder="16Gi" />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
GPU
|
||||
<Input value={quotaGpu} onChange={(e) => setQuotaGpu(e.target.value)} className="mt-1" placeholder="0" />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
GPU Mem
|
||||
<Input value={quotaGpuMemory} onChange={(e) => setQuotaGpuMemory(e.target.value)} className="mt-1" placeholder="0" />
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
CPU and memory use Kubernetes quantities. GPU memory is an integer MB value, for example 10000.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mustChangePassword}
|
||||
onChange={(event) => setMustChangePassword(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300"
|
||||
/>
|
||||
Require password change after first login
|
||||
</label>
|
||||
<Button type="submit" variant="primary" icon={UserPlus} loading={creating} className="w-full">
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="min-w-0 rounded-lg border border-slate-200 bg-white shadow-soft">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-slate-500" />
|
||||
<h2 className="text-base font-semibold text-slate-900">Accounts</h2>
|
||||
</div>
|
||||
<Badge variant="secondary" size="sm">{users.length} users</Badge>
|
||||
</div>
|
||||
{loading ? (
|
||||
<LoadingState message="Loading users..." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-5 py-3">User</th>
|
||||
<th className="px-5 py-3">Role</th>
|
||||
<th className="px-5 py-3">Status</th>
|
||||
<th className="px-5 py-3">Namespace</th>
|
||||
<th className="px-5 py-3">Quota</th>
|
||||
<th className="sticky right-0 z-10 bg-slate-50 px-5 py-3 text-right shadow-[-12px_0_18px_-18px_rgba(15,23,42,0.35)]">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{sortedUsers.map((target) => (
|
||||
<tr key={target.id} className="group hover:bg-slate-50">
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-medium text-slate-900">{target.username}</div>
|
||||
<div className="text-xs text-slate-500">{target.email}</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={target.role === "admin" ? "info" : "secondary"} size="sm">
|
||||
{target.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={target.isActive ? "success" : "warning"} size="sm">
|
||||
{target.isActive ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-mono text-xs text-slate-700">{target.namespace || "-"}</div>
|
||||
<div className="text-xs text-slate-500">{target.workspaceName || target.workspaceId}</div>
|
||||
{target.defaultClusterId && (
|
||||
<div className="mt-1 text-xs text-blue-700">{clusterName(clusters, target.defaultClusterId)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-slate-600">
|
||||
{target.role === "admin" ? (
|
||||
<span className="text-slate-400">default workspace</span>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
<span>CPU {target.quotaCpu || "-"}</span>
|
||||
<span>Mem {target.quotaMemory || "-"}</span>
|
||||
<span>GPU {target.quotaGpu || "0"} / Mem {target.quotaGpuMemory || "0"}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="sticky right-0 bg-white px-5 py-3 shadow-[-12px_0_18px_-18px_rgba(15,23,42,0.35)] group-hover:bg-slate-50">
|
||||
<div className="grid w-[260px] grid-cols-2 gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => toggleRole(target)}>
|
||||
Make {target.role === "admin" ? "User" : "Admin"}
|
||||
</Button>
|
||||
{target.role !== "admin" && (
|
||||
<Button type="button" variant="secondary" size="sm" icon={Pencil} onClick={() => openLimitsEditor(target)}>
|
||||
Limits
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => toggleActive(target)}
|
||||
disabled={target.id === user?.userId}
|
||||
>
|
||||
{target.isActive ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon={Trash2}
|
||||
onClick={() => handleDelete(target)}
|
||||
disabled={target.id === user?.userId}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{editingLimits && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 p-4 backdrop-blur-sm">
|
||||
<form onSubmit={saveLimits} className="w-full max-w-xl rounded-xl border border-slate-200 bg-white shadow-2xl">
|
||||
<div className="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
|
||||
<Gauge className="h-4 w-4" />
|
||||
Tenant limits
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-semibold text-slate-950">{editingLimits.username}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Changes are applied to workspace metadata and the next tenant binding/deploy refreshes Kubernetes ResourceQuota.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setEditingLimits(null)} className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-900">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Namespace
|
||||
<Input value={editNamespace} onChange={(e) => setEditNamespace(e.target.value)} className="mt-1 font-mono" required />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Default cluster
|
||||
<select
|
||||
value={editDefaultClusterId}
|
||||
onChange={(event) => setEditDefaultClusterId(event.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="">Select a cluster</option>
|
||||
{clusters.map((cluster) => (
|
||||
<option key={cluster.id} value={cluster.id}>
|
||||
{cluster.name || cluster.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
CPU
|
||||
<Input value={editQuotaCpu} onChange={(e) => setEditQuotaCpu(e.target.value)} className="mt-1" required />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Memory
|
||||
<Input value={editQuotaMemory} onChange={(e) => setEditQuotaMemory(e.target.value)} className="mt-1" required />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
GPU
|
||||
<Input value={editQuotaGpu} onChange={(e) => setEditQuotaGpu(e.target.value)} className="mt-1" required />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
GPU memory
|
||||
<Input value={editQuotaGpuMemory} onChange={(e) => setEditQuotaGpuMemory(e.target.value)} className="mt-1" placeholder="10000" required />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-slate-200 px-6 py-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setEditingLimits(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={savingLimits}>
|
||||
Save Limits
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const namespaceForUsername = (username: string): string => {
|
||||
const label = username
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return `ocdp-u-${label || "user"}`.slice(0, 63).replace(/-+$/g, "");
|
||||
};
|
||||
|
||||
const clusterName = (clusters: ClusterResponse[], clusterId: string): string => {
|
||||
const cluster = clusters.find((candidate) => candidate.id === clusterId);
|
||||
return cluster?.name || clusterId;
|
||||
};
|
||||
|
||||
export default UserManagementPage;
|
||||
Reference in New Issue
Block a user