重构 K8s 资源管理页面

1. 拆分原有的巨型 K8sResourceList.tsx 文件(1001行)为多个独立页面组件
2. 为每种 K8s 资源类型创建专门的页面:
   - Namespace: 简单名称输入创建
   - Deployment/StatefulSet: YAML 文件上传创建
   - Service: 显示"功能开发中"提示
   - ConfigMap: Key-Value 编辑器创建(支持文件上传)
   - Pod: 无创建功能
   - PV/PVC: YAML 文件上传创建
3. 创建共享组件和 Hooks 提高代码复用
4. 更新路由配置使用嵌套路由结构
5. 修复 PV/PVC 页面缺少组件导入的问题

优化了代码结构,使每个文件控制在合理大小范围内,便于维护和扩展。
This commit is contained in:
loveuer
2025-12-08 19:06:40 +08:00
parent 8b655d3496
commit c22845f83d
24 changed files with 3378 additions and 973 deletions

View File

@@ -0,0 +1,209 @@
import { useState, useRef } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
IconButton,
Typography,
Stack,
Alert,
MenuItem,
} from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import UploadFileIcon from '@mui/icons-material/UploadFile'
interface ConfigMapData {
key: string
value: string
}
interface CreateConfigMapDialogProps {
open: boolean
onClose: () => void
onSubmit: (name: string, namespace: string, data: Record<string, string>) => void
namespaces: string[]
}
export default function CreateConfigMapDialog({
open,
onClose,
onSubmit,
namespaces,
}: CreateConfigMapDialogProps) {
const [name, setName] = useState('')
const [namespace, setNamespace] = useState('')
const [configMapData, setConfigMapData] = useState<ConfigMapData[]>([{ key: '', value: '' }])
const [error, setError] = useState('')
const fileInputRefs = useRef<(HTMLInputElement | null)[]>([])
const handleAddData = () => {
setConfigMapData([...configMapData, { key: '', value: '' }])
}
const handleRemoveData = (index: number) => {
const newData = configMapData.filter((_, i) => i !== index)
setConfigMapData(newData.length === 0 ? [{ key: '', value: '' }] : newData)
}
const handleDataChange = (index: number, field: 'key' | 'value', value: string) => {
const newData = [...configMapData]
newData[index][field] = value
setConfigMapData(newData)
}
const handleFileUpload = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result as string
const newData = [...configMapData]
newData[index].value = content
if (!newData[index].key) {
newData[index].key = file.name
}
setConfigMapData(newData)
}
reader.readAsText(file)
}
const handleSubmit = () => {
if (!name.trim()) {
setError('请输入 ConfigMap 名称')
return
}
if (!namespace) {
setError('请选择 Namespace')
return
}
const hasEmptyKey = configMapData.some((d) => !d.key.trim())
if (hasEmptyKey) {
setError('所有 Key 不能为空')
return
}
const keys = configMapData.map((d) => d.key)
const uniqueKeys = new Set(keys)
if (keys.length !== uniqueKeys.size) {
setError('Key 不能重复')
return
}
const data: Record<string, string> = {}
configMapData.forEach((d) => {
data[d.key] = d.value
})
onSubmit(name, namespace, data)
handleClose()
}
const handleClose = () => {
setName('')
setNamespace('')
setConfigMapData([{ key: '', value: '' }])
setError('')
onClose()
}
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle> ConfigMap</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{error && <Alert severity="error">{error}</Alert>}
<TextField
label="Namespace"
select
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
fullWidth
required
>
{namespaces.map((ns) => (
<MenuItem key={ns} value={ns}>
{ns}
</MenuItem>
))}
</TextField>
<TextField
label="ConfigMap 名称"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
required
/>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1">Data</Typography>
<Button startIcon={<AddIcon />} onClick={handleAddData} size="small">
Key
</Button>
</Box>
{configMapData.map((data, index) => (
<Box key={index} sx={{ mb: 2, p: 2, border: '1px solid #e0e0e0', borderRadius: 1 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 1, alignItems: 'center' }}>
<TextField
label="Key"
value={data.key}
onChange={(e) => handleDataChange(index, 'key', e.target.value)}
size="small"
sx={{ flex: 1 }}
required
/>
<input
type="file"
ref={(el) => (fileInputRefs.current[index] = el)}
style={{ display: 'none' }}
onChange={(e) => handleFileUpload(index, e)}
/>
<IconButton
size="small"
onClick={() => fileInputRefs.current[index]?.click()}
title="从文件上传"
>
<UploadFileIcon />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => handleRemoveData(index)}
disabled={configMapData.length === 1}
>
<DeleteIcon />
</IconButton>
</Box>
<TextField
label="Value"
value={data.value}
onChange={(e) => handleDataChange(index, 'value', e.target.value)}
multiline
rows={4}
fullWidth
size="small"
/>
</Box>
))}
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}></Button>
<Button onClick={handleSubmit} variant="contained">
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,83 @@
import { useState } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Alert,
Box,
Typography,
IconButton,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
interface CreateNamespaceDialogProps {
open: boolean
onClose: () => void
onSubmit: (name: string) => void
}
export default function CreateNamespaceDialog({
open,
onClose,
onSubmit,
}: CreateNamespaceDialogProps) {
const [name, setName] = useState('')
const [error, setError] = useState('')
const handleSubmit = () => {
if (!name.trim()) {
setError('请输入 Namespace 名称')
return
}
if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name)) {
setError('Namespace 名称只能包含小写字母、数字和连字符,且必须以字母或数字开头和结尾')
return
}
onSubmit(name)
handleClose()
}
const handleClose = () => {
setName('')
setError('')
onClose()
}
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6"> Namespace</Typography>
<IconButton onClick={handleClose}>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 1 }}>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<TextField
label="Namespace 名称"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
required
placeholder="例如: my-namespace"
helperText="只能包含小写字母、数字和连字符"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}></Button>
<Button onClick={handleSubmit} variant="contained">
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,101 @@
import { useRef } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Stack,
Box,
Typography,
IconButton,
TextField,
CircularProgress,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import UploadFileIcon from '@mui/icons-material/UploadFile'
interface CreateYamlDialogProps {
open: boolean
onClose: () => void
onApply: (yaml: string) => void
yamlContent: string
onYamlChange: (value: string) => void
loading: boolean
}
export default function CreateYamlDialog({
open,
onClose,
onApply,
yamlContent,
onYamlChange,
loading,
}: CreateYamlDialogProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result as string
onYamlChange(content)
}
reader.readAsText(file)
}
}
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6"></Typography>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Box>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
<Button
variant="outlined"
startIcon={<UploadFileIcon />}
onClick={() => fileInputRef.current?.click()}
>
YAML
</Button>
</Box>
<TextField
label="YAML 内容"
multiline
rows={20}
value={yamlContent}
onChange={(e) => onYamlChange(e.target.value)}
placeholder="粘贴或编辑 YAML 内容..."
fullWidth
variant="outlined"
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}></Button>
<Button
variant="contained"
onClick={() => onApply(yamlContent)}
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : '应用'}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,52 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
CircularProgress,
} from '@mui/material'
interface DeleteConfirmDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
resourceType: string
resourceName: string
namespace?: string
deleting: boolean
}
export default function DeleteConfirmDialog({
open,
onClose,
onConfirm,
resourceType,
resourceName,
namespace,
deleting,
}: DeleteConfirmDialogProps) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography>
{resourceType} <strong>{resourceName}</strong>
{namespace && resourceType !== 'Namespace' ? ` (namespace: ${namespace})` : ''}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}></Button>
<Button
variant="contained"
color="error"
onClick={onConfirm}
disabled={deleting}
>
{deleting ? <CircularProgress size={24} /> : '删除'}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,73 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
IconButton,
TextField,
CircularProgress,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
interface EditResourceDialogProps {
open: boolean
onClose: () => void
onSave: (yaml: string) => void
resourceType: string
resourceName: string
namespace: string
yaml: string
onYamlChange: (value: string) => void
saving: boolean
}
export default function EditResourceDialog({
open,
onClose,
onSave,
resourceType,
resourceName,
namespace,
yaml,
onYamlChange,
saving,
}: EditResourceDialogProps) {
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">
{resourceType}: {resourceName} ({namespace})
</Typography>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<TextField
multiline
rows={20}
value={yaml}
onChange={(e) => onYamlChange(e.target.value)}
placeholder="YAML 内容..."
fullWidth
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}></Button>
<Button
variant="contained"
onClick={() => onSave(yaml)}
disabled={saving}
>
{saving ? <CircularProgress size={24} /> : '保存'}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,60 @@
import {
Drawer,
Box,
Typography,
IconButton,
Divider,
Stack,
TextField,
Button,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
interface KubeconfigSettingsDrawerProps {
open: boolean
onClose: () => void
kubeconfig: string
onKubeconfigChange: (value: string) => void
onSave: () => void
}
export default function KubeconfigSettingsDrawer({
open,
onClose,
kubeconfig,
onKubeconfigChange,
onSave,
}: KubeconfigSettingsDrawerProps) {
return (
<Drawer
anchor="right"
open={open}
onClose={onClose}
sx={{ '& .MuiDrawer-paper': { width: 500 } }}
>
<Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6"></Typography>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
<Divider sx={{ mb: 2 }} />
<Stack spacing={2}>
<TextField
label="Kubeconfig"
multiline
rows={20}
value={kubeconfig}
onChange={(e) => onKubeconfigChange(e.target.value)}
placeholder="粘贴 kubeconfig 内容..."
fullWidth
/>
<Button variant="contained" onClick={onSave}>
</Button>
</Stack>
</Box>
</Drawer>
)
}

View File

@@ -0,0 +1,77 @@
import { useRef, useEffect } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
IconButton,
Paper,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
interface PodLogsDialogProps {
open: boolean
onClose: () => void
podName: string
namespace: string
logs: string[]
}
export default function PodLogsDialog({
open,
onClose,
podName,
namespace,
logs,
}: PodLogsDialogProps) {
const logsEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [logs])
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">
Pod : {podName} ({namespace})
</Typography>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<Paper
sx={{
p: 2,
bgcolor: '#1e1e1e',
color: '#d4d4d4',
fontFamily: 'monospace',
fontSize: '0.875rem',
maxHeight: '60vh',
overflowY: 'auto',
}}
>
{logs.length === 0 ? (
<Typography color="inherit">...</Typography>
) : (
logs.map((log, index) => (
<Box key={index} sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{log}
</Box>
))
)}
<div ref={logsEndRef} />
</Paper>
</DialogContent>
<DialogActions>
<Button onClick={onClose}></Button>
</DialogActions>
</Dialog>
)
}