@@ -0,0 +1,664 @@
// ResourceVisualEditor.tsx — Kubernetes 资源可视化编辑器
// 支持:ConfigMap / Secret / Service / Ingress / PVC
// 替代纯 YAML 编辑,提供表单式交互体验
// 作者: Logan <admin@attacker.club>
import { useState , useEffect , useCallback } from 'react' ;
import { X , Save , RefreshCw , Loader2 , Plus , Trash2 , FileText , Network , Shield , Database , ChevronRight } from 'lucide-react' ;
import { Button } from '@/components/ui/button' ;
import { Input } from '@/components/ui/input' ;
import { Label } from '@/components/ui/label' ;
import { Textarea } from '@/components/ui/textarea' ;
import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from '@/components/ui/select' ;
import { useToast } from '@/hooks/use-toast' ;
import { kubernetesApi } from '@/services/api' ;
import CodeMirror from '@uiw/react-codemirror' ;
import { yaml } from '@codemirror/lang-yaml' ;
import { oneDark } from '@codemirror/theme-one-dark' ;
import { cleanYAML } from './k8s-yaml-utils' ;
interface ResourceVisualEditorProps {
clusterCode : string ;
namespace : string ;
resource : string ; // 'configmaps' | 'secrets' | 'services' | 'ingresses' | 'pvcs'
name : string ;
onClose : ( ) = > void ;
onSaved ? : ( ) = > void ;
}
// Simple YAML→JS parser (avoids js-yaml dependency — K8s API returns JSON-compatible YAML)
function tryParseYAML ( text : string ) : any {
try { return JSON . parse ( text ) ; } catch { }
// Fallback: try trimming and re-parsing as JSON (K8s API mostly returns JSON-serialized YAML)
try {
const lines = text . split ( '\n' ) . filter ( l = > ! l . trim ( ) . startsWith ( '#' ) ) ;
return JSON . parse ( lines . join ( '\n' ) ) ;
} catch { return null ; }
}
export function ResourceVisualEditor ( { clusterCode , namespace , resource , name , onClose , onSaved } : ResourceVisualEditorProps ) {
const { toast } = useToast ( ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ rawYaml , setRawYaml ] = useState ( '' ) ;
const [ manifest , setManifest ] = useState < any > ( null ) ;
// Track which tab: 'visual' | 'yaml'
const [ mode , setMode ] = useState < 'visual' | 'yaml' > ( 'visual' ) ;
// YAML editor state (for fallback yaml mode)
const [ editYaml , setEditYaml ] = useState ( '' ) ;
const loadData = useCallback ( async ( ) = > {
setLoading ( true ) ;
try {
const text = await kubernetesApi . getResourceYaml ( clusterCode , namespace , resource , name ) ;
const cleaned = cleanYAML ( text ) ;
setRawYaml ( text ) ;
setEditYaml ( cleaned ) ;
const parsed = tryParseYAML ( cleaned ) ;
setManifest ( parsed ) ;
if ( ! parsed ) {
// Can't parse — fall back to YAML mode
setMode ( 'yaml' ) ;
toast ( { title : '可视化编辑不可用' , description : '无法解析资源结构,已切换到 YAML 编辑模式' } ) ;
}
} catch ( e : any ) {
toast ( { title : '加载失败' , description : e.message , variant : 'destructive' } ) ;
} finally { setLoading ( false ) ; }
} , [ clusterCode , namespace , resource , name ] ) ;
useEffect ( ( ) = > { loadData ( ) ; } , [ loadData ] ) ;
const handleSave = async ( updatedManifest : any ) = > {
setSaving ( true ) ;
try {
// Serialize manifest back to YAML
const yamlStr = manifestToYaml ( updatedManifest ) ;
await kubernetesApi . updateResourceYaml ( clusterCode , namespace , resource , name , yamlStr ) ;
toast ( { title : '保存成功' , description : ` ${ resource } / ${ name } 已更新 ` } ) ;
onSaved ? . ( ) ;
onClose ( ) ;
} catch ( e : any ) {
toast ( { title : '保存失败' , description : e.message , variant : 'destructive' } ) ;
} finally { setSaving ( false ) ; }
} ;
const handleSaveYaml = async ( ) = > {
setSaving ( true ) ;
try {
await kubernetesApi . updateResourceYaml ( clusterCode , namespace , resource , name , editYaml ) ;
toast ( { title : 'YAML 已应用' , description : ` ${ resource } / ${ name } 更新成功 ` } ) ;
onSaved ? . ( ) ;
onClose ( ) ;
} catch ( e : any ) {
toast ( { title : '保存失败' , description : e.message , variant : 'destructive' } ) ;
} finally { setSaving ( false ) ; }
} ;
// Manifest → YAML string (JSON serialization works as YAML superset for K8s API)
function manifestToYaml ( m : any ) : string {
// Use 2-space indented JSON — K8s API accepts JSON as YAML
return JSON . stringify ( m , null , 2 ) ;
}
// ─── Resource type config ───
const resourceMeta : Record < string , { title : string ; icon : any ; color : string } > = {
configmaps : { title : 'ConfigMap' , icon : FileText , color : 'text-blue-400' } ,
secrets : { title : 'Secret' , icon : Shield , color : 'text-amber-400' } ,
services : { title : 'Service' , icon : Network , color : 'text-cyan-400' } ,
ingresses : { title : 'Ingress' , icon : ChevronRight , color : 'text-purple-400' } ,
pvcs : { title : 'PersistentVolumeClaim' , icon : Database , color : 'text-emerald-400' } ,
} ;
const meta = resourceMeta [ resource ] || resourceMeta . configmaps ;
const MetaIcon = meta . icon ;
return (
< div className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick = { onClose } >
< div className = "bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl w-[90vw] max-w-3xl max-h-[85vh] flex flex-col shadow-2xl" onClick = { e = > e . stopPropagation ( ) } >
{ /* Header */ }
< div className = "flex items-center justify-between p-4 border-b border-[var(--dg-border)]" >
< div className = "flex items-center gap-2" >
< MetaIcon className = { ` h-5 w-5 ${ meta . color } ` } / >
< div >
< h2 className = "text-lg font-bold text-[var(--dg-text-primary)]" > { meta . title } 编 辑 器 < / h2 >
< p className = "text-xs text-[var(--dg-text-muted)]" > { name } · { namespace } · { clusterCode } < / p >
< / div >
< / div >
< div className = "flex items-center gap-2" >
{ /* Mode switcher */ }
< div className = "flex rounded-lg border border-[var(--dg-border)] overflow-hidden" >
< button
className = { ` px-3 py-1.5 text-xs font-medium transition-colors ${ mode === 'visual' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]' } ` }
onClick = { ( ) = > manifest && setMode ( 'visual' ) }
disabled = { ! manifest }
> 可 视 化 编 辑 < / button >
< button
className = { ` px-3 py-1.5 text-xs font-medium transition-colors ${ mode === 'yaml' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]' } ` }
onClick = { ( ) = > setMode ( 'yaml' ) }
> YAML 编 辑 < / button >
< / div >
< Button variant = "ghost" size = "icon" className = "h-8 w-8 text-[var(--dg-text-secondary)]" onClick = { onClose } > < X className = "h-4 w-4" / > < / Button >
< / div >
< / div >
{ /* Body */ }
< div className = "flex-1 overflow-auto p-4" >
{ loading ? (
< div className = "flex items-center justify-center h-full py-20" > < Loader2 className = "h-6 w-6 animate-spin text-[var(--dg-text-muted)]" / > < / div >
) : mode === 'yaml' ? (
< div className = "space-y-3" >
< p className = "text-xs text-amber-400/80" > ⚠ ️ YAML 编 辑 模 式 — 修 改 后 点 击 保 存 。 已 自 动 清 理 只 读 字 段 ( status / managedFields / uid ) < / p >
< CodeMirror
value = { editYaml }
height = "550px"
theme = { oneDark }
extensions = { [ yaml ( ) ] }
onChange = { ( val ) = > setEditYaml ( val ) }
basicSetup = { { lineNumbers : true , foldGutter : true , highlightActiveLine : true , autocompletion : true } }
style = { { fontSize : '12px' } }
/ >
< div className = "flex justify-end gap-2" >
< Button variant = "outline" size = "sm" onClick = { ( ) = > { setEditYaml ( cleanYAML ( rawYaml ) ) ; } } > 重 置 < / Button >
< Button size = "sm" className = "bg-blue-600 hover:bg-blue-700" onClick = { handleSaveYaml } disabled = { saving } >
{ saving ? < Loader2 className = "h-3.5 w-3.5 animate-spin mr-1" / > : < Save className = "h-3.5 w-3.5 mr-1" / > }
保 存
< / Button >
< / div >
< / div >
) : manifest ? (
< >
{ resource === 'configmaps' && < ConfigMapEditor manifest = { manifest } onSave = { handleSave } saving = { saving } / > }
{ resource === 'secrets' && < SecretEditor manifest = { manifest } onSave = { handleSave } saving = { saving } / > }
{ resource === 'services' && < ServiceEditor manifest = { manifest } onSave = { handleSave } saving = { saving } / > }
{ resource === 'ingresses' && < IngressEditor manifest = { manifest } onSave = { handleSave } saving = { saving } / > }
{ resource === 'pvcs' && < PvcEditor manifest = { manifest } onSave = { handleSave } saving = { saving } / > }
{ ! [ 'configmaps' , 'secrets' , 'services' , 'ingresses' , 'pvcs' ] . includes ( resource ) && (
< div className = "text-center py-12 text-[var(--dg-text-muted)]" >
< p > 该 资 源 类 型 暂 不 支 持 可 视 化 编 辑 < / p >
< Button variant = "outline" size = "sm" className = "mt-4" onClick = { ( ) = > setMode ( 'yaml' ) } > 切 换 到 YAML 编 辑 < / Button >
< / div >
) }
< / >
) : (
< div className = "text-center py-12 text-[var(--dg-text-muted)]" >
< p > 无 法 解 析 资 源 结 构 < / p >
< Button variant = "outline" size = "sm" className = "mt-4" onClick = { ( ) = > setMode ( 'yaml' ) } > 切 换 到 YAML 编 辑 < / Button >
< / div >
) }
< / div >
< / div >
< / div >
) ;
}
// ══════════════════════════════════════════════
// ConfigMap 编辑器 — 键值对表格
// ══════════════════════════════════════════════
function ConfigMapEditor ( { manifest , onSave , saving } : { manifest : any ; onSave : ( m : any ) = > void ; saving : boolean } ) {
const data = manifest ? . data || { } ;
const [ entries , setEntries ] = useState < { key : string ; value : string } [ ] > (
Object . entries ( data ) . map ( ( [ key , value ] ) = > ( { key , value : String ( value ) } ) )
) ;
const update = ( i : number , field : 'key' | 'value' , val : string ) = > {
const next = [ . . . entries ] ;
next [ i ] = { . . . next [ i ] , [ field ] : val } ;
setEntries ( next ) ;
} ;
const addRow = ( ) = > setEntries ( [ . . . entries , { key : '' , value : '' } ] ) ;
const removeRow = ( i : number ) = > setEntries ( entries . filter ( ( _ , idx ) = > idx !== i ) ) ;
const handleSave = ( ) = > {
const newData : Record < string , string > = { } ;
entries . forEach ( e = > {
if ( e . key . trim ( ) ) newData [ e . key . trim ( ) ] = e . value ;
} ) ;
onSave ( { . . . manifest , data : newData } ) ;
} ;
return (
< div className = "space-y-4" >
< div >
< Label className = "text-[var(--dg-text-secondary)] text-xs" > 名 称 < / Label >
< Input value = { manifest ? . metadata ? . name || '' } readOnly className = "mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" / >
< / div >
< div >
< div className = "flex items-center justify-between mb-2" >
< Label className = "text-[var(--dg-text-secondary)] text-sm" > 配 置 数 据 ( 键 值 对 ) < / Label >
< Button variant = "outline" size = "sm" className = "h-7 text-xs" onClick = { addRow } > < Plus className = "h-3 w-3 mr-1" / > 添 加 < / Button >
< / div >
< div className = "space-y-2 max-h-[45vh] overflow-y-auto pr-1" >
{ entries . map ( ( entry , i ) = > (
< div key = { i } className = "flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]" >
< Input
value = { entry . key }
onChange = { e = > update ( i , 'key' , e . target . value ) }
placeholder = "键名 (如 LOG_LEVEL)"
className = "flex-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/ >
< Textarea
value = { entry . value }
onChange = { e = > update ( i , 'value' , e . target . value ) }
placeholder = "值"
className = "flex-[2] font-mono text-xs min-h-[36px] max-h-[120px] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/ >
< Button variant = "ghost" size = "icon" className = "h-8 w-8 text-red-400 hover:text-red-300 shrink-0" onClick = { ( ) = > removeRow ( i ) } >
< Trash2 className = "h-3.5 w-3.5" / >
< / Button >
< / div >
) ) }
{ entries . length === 0 && (
< div className = "text-center py-8 text-[var(--dg-text-muted)] text-xs" > 暂 无 配 置 数 据 , 点 击 「 添 加 」 创 建 < / div >
) }
< / div >
< / div >
< div className = "flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]" >
< Button size = "sm" className = "bg-blue-600 hover:bg-blue-700" onClick = { handleSave } disabled = { saving } >
{ saving ? < Loader2 className = "h-3.5 w-3.5 animate-spin mr-1" / > : < Save className = "h-3.5 w-3.5 mr-1" / > }
保 存 配 置
< / Button >
< / div >
< / div >
) ;
}
// ══════════════════════════════════════════════
// Secret 编辑器 — 键值对表格 (Base64 编码/解码)
// ══════════════════════════════════════════════
function SecretEditor ( { manifest , onSave , saving } : { manifest : any ; onSave : ( m : any ) = > void ; saving : boolean } ) {
const rawData = manifest ? . data || { } ;
// Decode base64 values for editing
const [ entries , setEntries ] = useState < { key : string ; value : string ; encoded : boolean } [ ] > (
Object . entries ( rawData ) . map ( ( [ key , value ] ) = > {
const v = String ( value ) ;
try {
// Try to decode — if it's valid base64
const decoded = atob ( v ) ;
return { key , value : decoded , encoded : true } ;
} catch {
return { key , value : v , encoded : false } ;
}
} )
) ;
const update = ( i : number , field : 'key' | 'value' , val : string ) = > {
const next = [ . . . entries ] ;
next [ i ] = { . . . next [ i ] , [ field ] : val } ;
setEntries ( next ) ;
} ;
const addRow = ( ) = > setEntries ( [ . . . entries , { key : '' , value : '' , encoded : true } ] ) ;
const removeRow = ( i : number ) = > setEntries ( entries . filter ( ( _ , idx ) = > idx !== i ) ) ;
const handleSave = ( ) = > {
const newData : Record < string , string > = { } ;
entries . forEach ( e = > {
if ( e . key . trim ( ) ) {
// Re-encode if originally base64
newData [ e . key . trim ( ) ] = e . encoded ? btoa ( e . value ) : e . value ;
}
} ) ;
onSave ( { . . . manifest , data : newData } ) ;
} ;
return (
< div className = "space-y-4" >
< div >
< Label className = "text-[var(--dg-text-secondary)] text-xs" > 名 称 < / Label >
< Input value = { manifest ? . metadata ? . name || '' } readOnly className = "mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" / >
< / div >
< div >
< Label className = "text-[var(--dg-text-secondary)] text-xs" > 类 型 < / Label >
< Input value = { manifest ? . type || 'Opaque' } readOnly className = "mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" / >
< / div >
< div >
< div className = "flex items-center justify-between mb-2" >
< Label className = "text-[var(--dg-text-secondary)] text-sm" > 密 钥 数 据 ( 已 自 动 解 码 Base64 ) < / Label >
< Button variant = "outline" size = "sm" className = "h-7 text-xs" onClick = { addRow } > < Plus className = "h-3 w-3 mr-1" / > 添 加 < / Button >
< / div >
< div className = "space-y-2 max-h-[45vh] overflow-y-auto pr-1" >
{ entries . map ( ( entry , i ) = > (
< div key = { i } className = "flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]" >
< Input
value = { entry . key }
onChange = { e = > update ( i , 'key' , e . target . value ) }
placeholder = "键名 (如 password)"
className = "flex-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/ >
< Input
value = { entry . value }
onChange = { e = > update ( i , 'value' , e . target . value ) }
placeholder = "明文值"
className = "flex-[2] font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/ >
< Button variant = "ghost" size = "icon" className = "h-8 w-8 text-red-400 hover:text-red-300 shrink-0" onClick = { ( ) = > removeRow ( i ) } >
< Trash2 className = "h-3.5 w-3.5" / >
< / Button >
< / div >
) ) }
{ entries . length === 0 && (
< div className = "text-center py-8 text-[var(--dg-text-muted)] text-xs" > 暂 无 密 钥 数 据 , 点 击 「 添 加 」 创 建 < / div >
) }
< / div >
< / div >
< div className = "flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]" >
< Button size = "sm" className = "bg-blue-600 hover:bg-blue-700" onClick = { handleSave } disabled = { saving } >
{ saving ? < Loader2 className = "h-3.5 w-3.5 animate-spin mr-1" / > : < Save className = "h-3.5 w-3.5 mr-1" / > }
保 存 密 钥
< / Button >
< / div >
< / div >
) ;
}
// ══════════════════════════════════════════════
// Service 编辑器 — 端口配置 + 类型 + 选择器
// ══════════════════════════════════════════════
function ServiceEditor ( { manifest , onSave , saving } : { manifest : any ; onSave : ( m : any ) = > void ; saving : boolean } ) {
const spec = manifest ? . spec || { } ;
const [ svcType , setSvcType ] = useState ( spec . type || 'ClusterIP' ) ;
const [ ports , setPorts ] = useState < any [ ] > (
( spec . ports || [ ] ) . map ( ( p : any ) = > ( {
name : p.name || '' ,
port : String ( p . port || '' ) ,
targetPort : String ( p . targetPort || '' ) ,
protocol : p.protocol || 'TCP' ,
nodePort : p.nodePort ? String ( p . nodePort ) : '' ,
} ) )
) ;
const [ selector , setSelector ] = useState < { key : string ; value : string } [ ] > (
Object . entries ( spec . selector || { } ) . map ( ( [ key , value ] ) = > ( { key , value : String ( value ) } ) )
) ;
const updatePort = ( i : number , field : string , val : string ) = > {
const next = [ . . . ports ] ;
next [ i ] = { . . . next [ i ] , [ field ] : val } ;
setPorts ( next ) ;
} ;
const addPort = ( ) = > setPorts ( [ . . . ports , { name : '' , port : '' , targetPort : '' , protocol : 'TCP' , nodePort : '' } ] ) ;
const removePort = ( i : number ) = > setPorts ( ports . filter ( ( _ , idx ) = > idx !== i ) ) ;
const updateSelector = ( i : number , field : 'key' | 'value' , val : string ) = > {
const next = [ . . . selector ] ;
next [ i ] = { . . . next [ i ] , [ field ] : val } ;
setSelector ( next ) ;
} ;
const addSelector = ( ) = > setSelector ( [ . . . selector , { key : '' , value : '' } ] ) ;
const removeSelector = ( i : number ) = > setSelector ( selector . filter ( ( _ , idx ) = > idx !== i ) ) ;
const handleSave = ( ) = > {
const newSpec : any = { . . . spec } ;
newSpec . type = svcType ;
newSpec . ports = ports . map ( p = > {
const port : any = {
port : parseInt ( p . port ) || 80 ,
targetPort : parseInt ( p . targetPort ) || parseInt ( p . port ) || 80 ,
protocol : p.protocol ,
} ;
if ( p . name ) port . name = p . name ;
if ( p . nodePort && svcType === 'NodePort' ) port . nodePort = parseInt ( p . nodePort ) ;
return port ;
} ) ;
const selObj : Record < string , string > = { } ;
selector . forEach ( s = > { if ( s . key . trim ( ) ) selObj [ s . key . trim ( ) ] = s . value ; } ) ;
newSpec . selector = selObj ;
onSave ( { . . . manifest , spec : newSpec } ) ;
} ;
return (
< div className = "space-y-5" >
< div className = "grid grid-cols-2 gap-4" >
< div >
< Label className = "text-[var(--dg-text-secondary)] text-xs" > 名 称 < / Label >
< Input value = { manifest ? . metadata ? . name || '' } readOnly className = "mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" / >
< / div >
< div >
< Label className = "text-[var(--dg-text-secondary)] text-xs" > Service 类 型 < / Label >
< Select value = { svcType } onValueChange = { setSvcType } >
< SelectTrigger className = "mt-1 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs" >
< SelectValue / >
< / SelectTrigger >
< SelectContent >
< SelectItem value = "ClusterIP" > ClusterIP ( 集 群 内 部 ) < / SelectItem >
< SelectItem value = "NodePort" > NodePort ( 节 点 端 口 ) < / SelectItem >
< SelectItem value = "LoadBalancer" > LoadBalancer ( 负 载 均 衡 ) < / SelectItem >
< / SelectContent >
< / Select >
< / div >
< / div >
{ /* Ports */ }
< div >
< div className = "flex items-center justify-between mb-2" >
< Label className = "text-[var(--dg-text-secondary)] text-sm" > 端 口 映 射 < / Label >
< Button variant = "outline" size = "sm" className = "h-7 text-xs" onClick = { addPort } > < Plus className = "h-3 w-3 mr-1" / > 添 加 端 口 < / Button >
< / div >
< div className = "space-y-2" >
{ ports . map ( ( p , i ) = > (
< div key = { i } className = "grid grid-cols-[1fr_80px_80px_80px_80px_32px] gap-2 items-center" >
< Input value = { p . name } onChange = { e = > updatePort ( i , 'name' , e . target . value ) } placeholder = "名称" className = "font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" / >
< Input value = { p . port } onChange = { e = > updatePort ( i , 'port' , e . target . value ) } placeholder = "端口" type = "number" className = "font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" / >
< Input value = { p . targetPort } onChange = { e = > updatePort ( i , 'targetPort' , e . target . value ) } placeholder = "目标" type = "number" className = "font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" / >
< Select value = { p . protocol } onValueChange = { v = > updatePort ( i , 'protocol' , v ) } >
< SelectTrigger className = "h-7 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" > < SelectValue / > < / SelectTrigger >
< SelectContent > < SelectItem value = "TCP" > TCP < / SelectItem > < SelectItem value = "UDP" > UDP < / SelectItem > < / SelectContent >
< / Select >
{ svcType === 'NodePort' ? (
< Input value = { p . nodePort } onChange = { e = > updatePort ( i , 'nodePort' , e . target . value ) } placeholder = "NodePort" type = "number" className = "font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" / >
) : < div / > }
< Button variant = "ghost" size = "icon" className = "h-7 w-7 text-red-400 hover:text-red-300" onClick = { ( ) = > removePort ( i ) } > < Trash2 className = "h-3 w-3" / > < / Button >
< / div >
) ) }
{ ports . length === 0 && < div className = "text-center py-4 text-[var(--dg-text-muted)] text-xs" > 暂 无 端 口 , 点 击 「 添 加 端 口 」 < / div > }
< / div >
< / div >
{ /* Selector */ }
< div >
< div className = "flex items-center justify-between mb-2" >
< Label className = "text-[var(--dg-text-secondary)] text-sm" > 选 择 器 ( Selector ) < / Label >
< Button variant = "outline" size = "sm" className = "h-7 text-xs" onClick = { addSelector } > < Plus className = "h-3 w-3 mr-1" / > 添 加 标 签 < / Button >
< / div >
< div className = "space-y-2" >
{ selector . map ( ( s , i ) = > (
< div key = { i } className = "flex items-center gap-2" >
< Input value = { s . key } onChange = { e = > updateSelector ( i , 'key' , e . target . value ) } placeholder = "键 (如 app)" className = "flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" / >
< span className = "text-[var(--dg-text-muted)] text-xs" > = < / span >
< Input value = { s . value } onChange = { e = > updateSelector ( i , 'value' , e . target . value ) } placeholder = "值 (如 nginx)" className = "flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" / >
< Button variant = "ghost" size = "icon" className = "h-7 w-7 text-red-400 hover:text-red-300" onClick = { ( ) = > removeSelector ( i ) } > < Trash2 className = "h-3 w-3" / > < / Button >
< / div >
) ) }
{ selector . length === 0 && < div className = "text-center py-4 text-[var(--dg-text-muted)] text-xs" > 暂 无 选 择 器 < / div > }
< / div >
< / div >
< div className = "flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]" >
< Button size = "sm" className = "bg-blue-600 hover:bg-blue-700" onClick = { handleSave } disabled = { saving } >
{ saving ? < Loader2 className = "h-3.5 w-3.5 animate-spin mr-1" / > : < Save className = "h-3.5 w-3.5 mr-1" / > }
保 存 Service
< / Button >
< / div >
< / div >
) ;
}
// ══════════════════════════════════════════════
// Ingress 编辑器 — Host/Path 路由规则
// ══════════════════════════════════════════════
function IngressEditor ( { manifest , onSave , saving } : { manifest : any ; onSave : ( m : any ) = > void ; saving : boolean } ) {
const spec = manifest ? . spec || { } ;
const [ rules , setRules ] = useState < { host : string ; paths : { path : string ; pathType : string ; serviceName : string ; servicePort : string } [ ] } [ ] > (
( spec . rules || [ ] ) . map ( ( r : any ) = > ( {
host : r.host || '' ,
paths : ( r . http ? . backend ? . servicePort != null
? // Old-style single backend
[ { path : '/' , pathType : 'Prefix' , serviceName : r.http?.backend?.serviceName || '' , servicePort : String ( r . http ? . backend ? . servicePort || '80' ) } ]
: ( r . http ? . paths || [ ] ) . map ( ( p : any ) = > ( {
path : p.path || '/' ,
pathType : p.pathType || 'Prefix' ,
serviceName : p.backend?.service?.name || p . backend ? . serviceName || '' ,
servicePort : String ( p . backend ? . service ? . port ? . number ? ? p . backend ? . servicePort ? ? '80' ) ,
} ) )
) ,
} ) )
) ;
const updateRule = ( ri : number , host : string ) = > {
const next = [ . . . rules ] ;
next [ ri ] = { . . . next [ ri ] , host } ;
setRules ( next ) ;
} ;
const addRule = ( ) = > setRules ( [ . . . rules , { host : '' , paths : [ { path : '/' , pathType : 'Prefix' , serviceName : '' , servicePort : '80' } ] } ] ) ;
const removeRule = ( ri : number ) = > setRules ( rules . filter ( ( _ , idx ) = > idx !== ri ) ) ;
const updatePath = ( ri : number , pi : number , field : string , val : string ) = > {
const next = [ . . . rules ] ;
next [ ri ] . paths [ pi ] = { . . . next [ ri ] . paths [ pi ] , [ field ] : val } ;
setRules ( next ) ;
} ;
const addPath = ( ri : number ) = > {
const next = [ . . . rules ] ;
next [ ri ] . paths . push ( { path : '/' , pathType : 'Prefix' , serviceName : '' , servicePort : '80' } ) ;
setRules ( next ) ;
} ;
const removePath = ( ri : number , pi : number ) = > {
const next = [ . . . rules ] ;
next [ ri ] . paths = next [ ri ] . paths . filter ( ( _ , idx ) = > idx !== pi ) ;
setRules ( next ) ;
} ;
const handleSave = ( ) = > {
const newRules = rules . map ( r = > ( {
host : r.host ,
http : {
paths : r.paths.map ( p = > ( {
path : p.path ,
pathType : p.pathType ,
backend : { service : { name : p.serviceName , port : { number : parseInt ( p . servicePort ) || 80 } } } ,
} ) ) ,
} ,
} ) ) ;
onSave ( { . . . manifest , spec : { . . . spec , rules : newRules } } ) ;
} ;
return (
< div className = "space-y-4" >
< div >
< Label className = "text-[var(--dg-text-secondary)] text-xs" > 名 称 < / Label >
< Input value = { manifest ? . metadata ? . name || '' } readOnly className = "mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" / >
< / div >
< div >
< div className = "flex items-center justify-between mb-2" >
< Label className = "text-[var(--dg-text-secondary)] text-sm" > 路 由 规 则 < / Label >
< Button variant = "outline" size = "sm" className = "h-7 text-xs" onClick = { addRule } > < Plus className = "h-3 w-3 mr-1" / > 添 加 Host < / Button >
< / div >
< div className = "space-y-3 max-h-[50vh] overflow-y-auto pr-1" >
{ rules . map ( ( rule , ri ) = > (
< div key = { ri } className = "p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] space-y-2" >
< div className = "flex items-center gap-2" >
< Input
value = { rule . host }
onChange = { e = > updateRule ( ri , e . target . value ) }
placeholder = "域名 (如 api.example.com)"
className = "flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/ >
< Button variant = "ghost" size = "icon" className = "h-7 w-7 text-red-400 hover:text-red-300" onClick = { ( ) = > removeRule ( ri ) } > < Trash2 className = "h-3 w-3" / > < / Button >
< / div >
{ rule . paths . map ( ( p , pi ) = > (
< div key = { pi } className = "grid grid-cols-[1fr_100px_1fr_60px_28px] gap-1.5 items-center pl-4" >
< Input value = { p . path } onChange = { e = > updatePath ( ri , pi , 'path' , e . target . value ) } placeholder = "路径 /" className = "font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" / >
< Select value = { p . pathType } onValueChange = { v = > updatePath ( ri , pi , 'pathType' , v ) } >
< SelectTrigger className = "h-7 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" > < SelectValue / > < / SelectTrigger >
< SelectContent > < SelectItem value = "Prefix" > Prefix < / SelectItem > < SelectItem value = "Exact" > Exact < / SelectItem > < / SelectContent >
< / Select >
< Input value = { p . serviceName } onChange = { e = > updatePath ( ri , pi , 'serviceName' , e . target . value ) } placeholder = "Service" className = "font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" / >
< Input value = { p . servicePort } onChange = { e = > updatePath ( ri , pi , 'servicePort' , e . target . value ) } placeholder = "端口" type = "number" className = "font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" / >
< Button variant = "ghost" size = "icon" className = "h-7 w-7 text-red-400 hover:text-red-300" onClick = { ( ) = > removePath ( ri , pi ) } > < Trash2 className = "h-3 w-3" / > < / Button >
< / div >
) ) }
< Button variant = "ghost" size = "sm" className = "h-6 text-xs text-[var(--dg-text-muted)]" onClick = { ( ) = > addPath ( ri ) } > < Plus className = "h-3 w-3 mr-1" / > 添 加 路 径 < / Button >
< / div >
) ) }
{ rules . length === 0 && < div className = "text-center py-4 text-[var(--dg-text-muted)] text-xs" > 暂 无 路 由 规 则 , 点 击 「 添 加 Host 」 < / div > }
< / div >
< / div >
< div className = "flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]" >
< Button size = "sm" className = "bg-blue-600 hover:bg-blue-700" onClick = { handleSave } disabled = { saving } >
{ saving ? < Loader2 className = "h-3.5 w-3.5 animate-spin mr-1" / > : < Save className = "h-3.5 w-3.5 mr-1" / > }
保 存 Ingress
< / Button >
< / div >
< / div >
) ;
}
// ══════════════════════════════════════════════
// PVC 编辑器 — 存储大小/访问模式/StorageClass
// ══════════════════════════════════════════════
function PvcEditor ( { manifest , onSave , saving } : { manifest : any ; onSave : ( m : any ) = > void ; saving : boolean } ) {
const spec = manifest ? . spec || { } ;
const resources = spec . resources ? . requests || { } ;
const [ size , setSize ] = useState ( resources . storage || '1Gi' ) ;
const [ accessMode , setAccessMode ] = useState ( spec . accessModes ? . [ 0 ] || 'ReadWriteOnce' ) ;
const [ storageClass , setStorageClass ] = useState ( spec . storageClassName || '' ) ;
const handleSave = ( ) = > {
onSave ( {
. . . manifest ,
spec : {
. . . spec ,
accessModes : [ accessMode ] ,
storageClassName : storageClass || undefined ,
resources : { requests : { storage : size } } ,
} ,
} ) ;
} ;
return (
< div className = "space-y-4" >
< div >
< Label className = "text-[var(--dg-text-secondary)] text-xs" > 名 称 < / Label >
< Input value = { manifest ? . metadata ? . name || '' } readOnly className = "mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" / >
< / div >
< div className = "grid grid-cols-2 gap-4" >
< div >
< Label className = "text-[var(--dg-text-secondary)] text-xs" > 存 储 大 小 < / Label >
< Input
value = { size }
onChange = { e = > setSize ( e . target . value ) }
placeholder = "如 10Gi"
className = "mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/ >
< p className = "text-[10px] text-[var(--dg-text-muted)] mt-1" > 单 位 : Gi ( GiB ) , Mi ( MiB ) , Ti ( TiB ) < / p >
< / div >
< div >
< Label className = "text-[var(--dg-text-secondary)] text-xs" > 访 问 模 式 < / Label >
< Select value = { accessMode } onValueChange = { setAccessMode } >
< SelectTrigger className = "mt-1 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs" >
< SelectValue / >
< / SelectTrigger >
< SelectContent >
< SelectItem value = "ReadWriteOnce" > ReadWriteOnce ( 单 节 点 读 写 ) < / SelectItem >
< SelectItem value = "ReadOnlyMany" > ReadOnlyMany ( 多 节 点 只 读 ) < / SelectItem >
< SelectItem value = "ReadWriteMany" > ReadWriteMany ( 多 节 点 读 写 ) < / SelectItem >
< / SelectContent >
< / Select >
< / div >
< / div >
< div >
< Label className = "text-[var(--dg-text-secondary)] text-xs" > StorageClass < / Label >
< Input
value = { storageClass }
onChange = { e = > setStorageClass ( e . target . value ) }
placeholder = "留空使用默认 StorageClass"
className = "mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/ >
< / div >
< div className = "flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]" >
< Button size = "sm" className = "bg-blue-600 hover:bg-blue-700" onClick = { handleSave } disabled = { saving } >
{ saving ? < Loader2 className = "h-3.5 w-3.5 animate-spin mr-1" / > : < Save className = "h-3.5 w-3.5 mr-1" / > }
保 存 PVC
< / Button >
< / div >
< / div >
) ;
}