重构 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:
@@ -1,11 +1,19 @@
|
|||||||
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack, Menu, MenuItem, Avatar } from '@mui/material'
|
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack, Menu, MenuItem, Avatar } from '@mui/material'
|
||||||
import { Routes, Route, Link, Navigate } from 'react-router-dom'
|
import { Routes, Route, Link, Navigate } from 'react-router-dom'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AccountCircle, Logout } from '@mui/icons-material'
|
import { Logout } from '@mui/icons-material'
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { useAuthStore } from './stores/authStore'
|
import { useAuthStore } from './stores/authStore'
|
||||||
import RegistryImageList from './pages/RegistryImageList'
|
import RegistryImageList from './pages/RegistryImageList'
|
||||||
import K8sResourceList from './pages/K8sResourceList'
|
import K8sLayout from './pages/k8s/K8sLayout'
|
||||||
|
import NamespacePage from './pages/k8s/NamespacePage'
|
||||||
|
import DeploymentPage from './pages/k8s/DeploymentPage'
|
||||||
|
import StatefulSetPage from './pages/k8s/StatefulSetPage'
|
||||||
|
import ServicePage from './pages/k8s/ServicePage'
|
||||||
|
import ConfigMapPage from './pages/k8s/ConfigMapPage'
|
||||||
|
import PodPage from './pages/k8s/PodPage'
|
||||||
|
import PVPage from './pages/k8s/PVPage'
|
||||||
|
import PVCPage from './pages/k8s/PVCPage'
|
||||||
import UserManagement from './pages/UserManagement'
|
import UserManagement from './pages/UserManagement'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
|
|
||||||
@@ -45,7 +53,7 @@ function App() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Button color="inherit" component={Link} to="/">首页</Button>
|
<Button color="inherit" component={Link} to="/">首页</Button>
|
||||||
<Button color="inherit" component={Link} to="/registry/image">镜像列表</Button>
|
<Button color="inherit" component={Link} to="/registry/image">镜像列表</Button>
|
||||||
<Button color="inherit" component={Link} to="/k8s/resources">集群资源</Button>
|
<Button color="inherit" component={Link} to="/k8s/configmap">集群资源</Button>
|
||||||
<Button color="inherit" component={Link} to="/users">用户管理</Button>
|
<Button color="inherit" component={Link} to="/users">用户管理</Button>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -92,7 +100,17 @@ function App() {
|
|||||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/registry/image" element={<RegistryImageList />} />
|
<Route path="/registry/image" element={<RegistryImageList />} />
|
||||||
<Route path="/k8s/resources" element={<K8sResourceList />} />
|
<Route path="/k8s" element={<K8sLayout />}>
|
||||||
|
<Route path="namespace" element={<NamespacePage />} />
|
||||||
|
<Route path="deployment" element={<DeploymentPage />} />
|
||||||
|
<Route path="statefulset" element={<StatefulSetPage />} />
|
||||||
|
<Route path="service" element={<ServicePage />} />
|
||||||
|
<Route path="configmap" element={<ConfigMapPage />} />
|
||||||
|
<Route path="pod" element={<PodPage />} />
|
||||||
|
<Route path="pv" element={<PVPage />} />
|
||||||
|
<Route path="pvc" element={<PVCPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/k8s/namespace" replace />} />
|
||||||
|
</Route>
|
||||||
<Route path="/users" element={<UserManagement />} />
|
<Route path="/users" element={<UserManagement />} />
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
209
frontend/src/components/CreateConfigMapDialog.tsx
Normal file
209
frontend/src/components/CreateConfigMapDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
frontend/src/components/k8s/CreateNamespaceDialog.tsx
Normal file
83
frontend/src/components/k8s/CreateNamespaceDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
frontend/src/components/k8s/CreateYamlDialog.tsx
Normal file
101
frontend/src/components/k8s/CreateYamlDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
frontend/src/components/k8s/DeleteConfirmDialog.tsx
Normal file
52
frontend/src/components/k8s/DeleteConfirmDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
frontend/src/components/k8s/EditResourceDialog.tsx
Normal file
73
frontend/src/components/k8s/EditResourceDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
frontend/src/components/k8s/KubeconfigSettingsDrawer.tsx
Normal file
60
frontend/src/components/k8s/KubeconfigSettingsDrawer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
frontend/src/components/k8s/PodLogsDialog.tsx
Normal file
77
frontend/src/components/k8s/PodLogsDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
frontend/src/hooks/useKubeconfig.ts
Normal file
54
frontend/src/hooks/useKubeconfig.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useKubeconfig() {
|
||||||
|
const [kubeconfig, setKubeconfig] = useState('')
|
||||||
|
const [kubeconfigError, setKubeconfigError] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchKubeconfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/config')
|
||||||
|
const result = await res.json()
|
||||||
|
setKubeconfig(result.data?.kubeconfig || '')
|
||||||
|
if (!result.data?.kubeconfig) {
|
||||||
|
setKubeconfigError(true)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch kubeconfig:', e)
|
||||||
|
setKubeconfigError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveKubeconfig = async (config: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ kubeconfig: config }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to save kubeconfig')
|
||||||
|
setKubeconfigError(false)
|
||||||
|
setKubeconfig(config)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save kubeconfig:', e)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchKubeconfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
kubeconfig,
|
||||||
|
kubeconfigError,
|
||||||
|
loading,
|
||||||
|
setKubeconfig,
|
||||||
|
saveKubeconfig,
|
||||||
|
refetch: fetchKubeconfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
39
frontend/src/hooks/useNamespaces.ts
Normal file
39
frontend/src/hooks/useNamespaces.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useNamespaces() {
|
||||||
|
const [namespaces, setNamespaces] = useState<string[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/namespace/list')
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch namespaces')
|
||||||
|
}
|
||||||
|
|
||||||
|
const namespaceNames = result.data?.items?.map((ns: any) => ns.metadata?.name) || []
|
||||||
|
setNamespaces(namespaceNames)
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
console.error('Failed to fetch namespaces:', e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNamespaces()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
namespaces,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchNamespaces,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,966 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Drawer,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemText,
|
|
||||||
Paper,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
CircularProgress,
|
|
||||||
Alert,
|
|
||||||
IconButton,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Divider,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Snackbar,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
AppBar,
|
|
||||||
Toolbar,
|
|
||||||
Tooltip,
|
|
||||||
} from '@mui/material'
|
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
|
||||||
import CloseIcon from '@mui/icons-material/Close'
|
|
||||||
import AddIcon from '@mui/icons-material/Add'
|
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile'
|
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility'
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
|
||||||
|
|
||||||
const KINDS = [
|
|
||||||
{ key: 'namespace', label: 'Namespace', endpoint: '/api/v1/k8s/namespace/list' },
|
|
||||||
{ key: 'deployment', label: 'Deployment', endpoint: '/api/v1/k8s/deployment/list' },
|
|
||||||
{ key: 'statefulset', label: 'StatefulSet', endpoint: '/api/v1/k8s/statefulset/list' },
|
|
||||||
{ key: 'service', label: 'Service', endpoint: '/api/v1/k8s/service/list' },
|
|
||||||
{ key: 'configmap', label: 'ConfigMap', endpoint: '/api/v1/k8s/configmap/list' },
|
|
||||||
{ key: 'pod', label: 'Pod', endpoint: '/api/v1/k8s/pod/list' },
|
|
||||||
{ key: 'pv', label: 'PersistentVolume', endpoint: '/api/v1/k8s/pv/list' },
|
|
||||||
{ key: 'pvc', label: 'PersistentVolumeClaim', endpoint: '/api/v1/k8s/pvc/list' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const DRAWER_WIDTH = 240
|
|
||||||
|
|
||||||
export default function K8sResourceList() {
|
|
||||||
const [selectedKind, setSelectedKind] = useState(KINDS[0])
|
|
||||||
const [resources, setResources] = useState<any[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
||||||
const [kubeconfig, setKubeconfig] = useState('')
|
|
||||||
const [kubeconfigError, setKubeconfigError] = useState(false)
|
|
||||||
const [namespace, setNamespace] = useState('')
|
|
||||||
const [namespaces, setNamespaces] = useState<string[]>([])
|
|
||||||
const [nameFilter, setNameFilter] = useState('')
|
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
|
||||||
const [yamlContent, setYamlContent] = useState('')
|
|
||||||
const [applyLoading, setApplyLoading] = useState(false)
|
|
||||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
|
||||||
open: false,
|
|
||||||
message: '',
|
|
||||||
severity: 'success',
|
|
||||||
})
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const [logsDialogOpen, setLogsDialogOpen] = useState(false)
|
|
||||||
const [logs, setLogs] = useState<string[]>([])
|
|
||||||
const [selectedPod, setSelectedPod] = useState<{ name: string; namespace: string } | null>(null)
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null)
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
|
||||||
const [editResource, setEditResource] = useState<{ name: string; namespace: string; kind: string; yaml: string } | null>(null)
|
|
||||||
const [editYaml, setEditYaml] = useState('')
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const logsEndRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchKubeconfig()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (kubeconfig) {
|
|
||||||
fetchResources()
|
|
||||||
if (selectedKind.key !== 'namespace' && selectedKind.key !== 'pv') {
|
|
||||||
fetchNamespaces()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [selectedKind, namespace, nameFilter])
|
|
||||||
|
|
||||||
// Clean up SSE connection on component unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
console.log('Cleaning up SSE connection on component unmount')
|
|
||||||
eventSourceRef.current.close()
|
|
||||||
eventSourceRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchKubeconfig = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/k8s/config')
|
|
||||||
const result = await res.json()
|
|
||||||
setKubeconfig(result.data?.kubeconfig || '')
|
|
||||||
if (!result.data?.kubeconfig) {
|
|
||||||
setKubeconfigError(true)
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Failed to fetch kubeconfig:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveKubeconfig = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/k8s/config', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ kubeconfig }),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error('Failed to save kubeconfig')
|
|
||||||
setKubeconfigError(false)
|
|
||||||
setSettingsOpen(false)
|
|
||||||
fetchResources()
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
setYamlContent(content)
|
|
||||||
}
|
|
||||||
reader.readAsText(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApplyYaml = async () => {
|
|
||||||
if (!yamlContent.trim()) {
|
|
||||||
setSnackbar({ open: true, message: 'YAML 内容不能为空', severity: 'error' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setApplyLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/k8s/resource/apply', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ yaml: yamlContent }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await res.json()
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(result.err || 'Failed to apply resource')
|
|
||||||
}
|
|
||||||
|
|
||||||
setSnackbar({ open: true, message: '资源应用成功', severity: 'success' })
|
|
||||||
setCreateDialogOpen(false)
|
|
||||||
setYamlContent('')
|
|
||||||
fetchResources()
|
|
||||||
} catch (e: any) {
|
|
||||||
setSnackbar({ open: true, message: `应用失败: ${e.message}`, severity: 'error' })
|
|
||||||
} finally {
|
|
||||||
setApplyLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleViewLogs = (podName: string, podNamespace: string) => {
|
|
||||||
console.log('handleViewLogs called with:', { podName, podNamespace })
|
|
||||||
setSelectedPod({ name: podName, namespace: podNamespace })
|
|
||||||
setLogs([])
|
|
||||||
setLogsDialogOpen(true)
|
|
||||||
|
|
||||||
// Close any existing connection
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
console.log('Closing existing EventSource connection')
|
|
||||||
eventSourceRef.current.close()
|
|
||||||
eventSourceRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventSource = new EventSource(
|
|
||||||
`/api/v1/k8s/pod/logs?name=${encodeURIComponent(podName)}&namespace=${encodeURIComponent(podNamespace)}&tail=1000&follow=true`
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save reference to the EventSource
|
|
||||||
eventSourceRef.current = eventSource
|
|
||||||
|
|
||||||
// Listen for the specific event type 'pod-logs'
|
|
||||||
eventSource.addEventListener('pod-logs', (event: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(event.data)
|
|
||||||
if (message.type === 'log') {
|
|
||||||
setLogs((prev) => [...prev, message.data])
|
|
||||||
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
|
|
||||||
} else if (message.type === 'EOF') {
|
|
||||||
// Handle end of stream if needed
|
|
||||||
} else if (message.type === 'error') {
|
|
||||||
setLogs((prev) => [...prev, `Error: ${message.data}`])
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, treat as plain text (fallback)
|
|
||||||
setLogs((prev) => [...prev, event.data])
|
|
||||||
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
console.log('EventSource error occurred')
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close()
|
|
||||||
eventSourceRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloseLogsDialog = () => {
|
|
||||||
console.log('handleCloseLogsDialog called')
|
|
||||||
// Close the EventSource connection if it exists
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
console.log('Closing EventSource connection')
|
|
||||||
eventSourceRef.current.close()
|
|
||||||
eventSourceRef.current = null
|
|
||||||
}
|
|
||||||
setLogsDialogOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteResource = async () => {
|
|
||||||
if (!deleteTarget) return
|
|
||||||
|
|
||||||
setDeleting(true)
|
|
||||||
try {
|
|
||||||
let endpoint = '';
|
|
||||||
let kind = '';
|
|
||||||
let requestBody = {
|
|
||||||
name: deleteTarget.name,
|
|
||||||
namespace: deleteTarget.namespace
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine the correct endpoint based on the selected resource kind
|
|
||||||
switch (selectedKind.key) {
|
|
||||||
case 'pod':
|
|
||||||
endpoint = '/api/v1/k8s/pod/delete'
|
|
||||||
kind = 'Pod'
|
|
||||||
break
|
|
||||||
case 'deployment':
|
|
||||||
endpoint = '/api/v1/k8s/deployment/delete'
|
|
||||||
kind = 'Deployment'
|
|
||||||
break
|
|
||||||
case 'statefulset':
|
|
||||||
endpoint = '/api/v1/k8s/statefulset/delete'
|
|
||||||
kind = 'StatefulSet'
|
|
||||||
break
|
|
||||||
case 'service':
|
|
||||||
endpoint = '/api/v1/k8s/service/delete'
|
|
||||||
kind = 'Service'
|
|
||||||
break
|
|
||||||
case 'configmap':
|
|
||||||
endpoint = '/api/v1/k8s/configmap/delete'
|
|
||||||
kind = 'ConfigMap'
|
|
||||||
break
|
|
||||||
case 'namespace':
|
|
||||||
endpoint = '/api/v1/k8s/namespace/delete'
|
|
||||||
kind = 'Namespace'
|
|
||||||
// Namespace doesn't need namespace field
|
|
||||||
requestBody = { name: deleteTarget.name }
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported resource kind: ${selectedKind.key}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await res.json()
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(result.err || `Failed to delete ${kind}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
setSnackbar({
|
|
||||||
open: true,
|
|
||||||
message: `${kind} 删除成功`,
|
|
||||||
severity: 'success'
|
|
||||||
})
|
|
||||||
setDeleteDialogOpen(false)
|
|
||||||
setDeleteTarget(null)
|
|
||||||
fetchResources()
|
|
||||||
} catch (e: any) {
|
|
||||||
setSnackbar({
|
|
||||||
open: true,
|
|
||||||
message: `删除失败: ${e.message}`,
|
|
||||||
severity: 'error'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setDeleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeletePod = async () => {
|
|
||||||
if (!deleteTarget) return
|
|
||||||
|
|
||||||
setDeleting(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/k8s/pod/delete', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: deleteTarget.name, namespace: deleteTarget.namespace }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await res.json()
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(result.err || 'Failed to delete pod')
|
|
||||||
}
|
|
||||||
|
|
||||||
setSnackbar({ open: true, message: 'Pod 删除成功', severity: 'success' })
|
|
||||||
setDeleteDialogOpen(false)
|
|
||||||
setDeleteTarget(null)
|
|
||||||
fetchResources()
|
|
||||||
} catch (e: any) {
|
|
||||||
setSnackbar({ open: true, message: `删除失败: ${e.message}`, severity: 'error' })
|
|
||||||
} finally {
|
|
||||||
setDeleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditResource = async (name: string, namespace: string, kind: string) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/v1/k8s/resource/get?name=${encodeURIComponent(name)}&namespace=${encodeURIComponent(namespace)}&kind=${encodeURIComponent(kind)}`)
|
|
||||||
const result = await res.json()
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(result.err || 'Failed to get resource')
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditResource({ name, namespace, kind, yaml: result.data.yaml })
|
|
||||||
setEditYaml(result.data.yaml)
|
|
||||||
setEditDialogOpen(true)
|
|
||||||
} catch (e: any) {
|
|
||||||
setSnackbar({ open: true, message: `获取资源失败: ${e.message}`, severity: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApplyEdit = async () => {
|
|
||||||
if (!editResource) return
|
|
||||||
|
|
||||||
setEditing(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/k8s/resource/update', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ yaml: editYaml }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await res.json()
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(result.err || 'Failed to update resource')
|
|
||||||
}
|
|
||||||
|
|
||||||
setSnackbar({ open: true, message: '资源更新成功', severity: 'success' })
|
|
||||||
setEditDialogOpen(false)
|
|
||||||
setEditResource(null)
|
|
||||||
setEditYaml('')
|
|
||||||
fetchResources()
|
|
||||||
} catch (e: any) {
|
|
||||||
setSnackbar({ open: true, message: `更新失败: ${e.message}`, severity: 'error' })
|
|
||||||
} finally {
|
|
||||||
setEditing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openDeleteDialog = (podName: string, podNamespace: string) => {
|
|
||||||
setDeleteTarget({ name: podName, namespace: podNamespace })
|
|
||||||
setDeleteDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchResources = async () => {
|
|
||||||
if (!kubeconfig) {
|
|
||||||
setKubeconfigError(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const url = new URL(selectedKind.endpoint, window.location.origin)
|
|
||||||
if (namespace && selectedKind.key !== 'namespace' && selectedKind.key !== 'pv') {
|
|
||||||
url.searchParams.set('namespace', namespace)
|
|
||||||
}
|
|
||||||
if (nameFilter) {
|
|
||||||
url.searchParams.set('name', nameFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url.toString())
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const result = await res.json()
|
|
||||||
setResources(result.data?.items || [])
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchNamespaces = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/k8s/namespace/list')
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const result = await res.json()
|
|
||||||
const namespaceList = result.data?.items?.map((ns: any) => ns.metadata.name) || []
|
|
||||||
setNamespaces(namespaceList)
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Failed to fetch namespaces:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getResourceColumns = () => {
|
|
||||||
switch (selectedKind.key) {
|
|
||||||
case 'namespace':
|
|
||||||
return ['Name', 'Status', 'Age', 'Actions']
|
|
||||||
case 'deployment':
|
|
||||||
case 'statefulset':
|
|
||||||
return ['Name', 'Namespace', 'Replicas', 'Age', 'Actions']
|
|
||||||
case 'service':
|
|
||||||
return ['Name', 'Namespace', 'Type', 'Cluster IP', 'Ports', 'NodePort', 'Age', 'Actions']
|
|
||||||
case 'configmap':
|
|
||||||
return ['Name', 'Namespace', 'Data Keys', 'Age', 'Actions']
|
|
||||||
case 'pod':
|
|
||||||
return ['Name', 'Namespace', 'Status', 'IP', 'Node', 'Age', 'Actions']
|
|
||||||
case 'pv':
|
|
||||||
return ['Name', 'Capacity', 'Access Modes', 'Status', 'Claim', 'Age', 'Actions']
|
|
||||||
case 'pvc':
|
|
||||||
return ['Name', 'Namespace', 'Status', 'Volume', 'Capacity', 'Access Modes', 'Age', 'Actions']
|
|
||||||
default:
|
|
||||||
return ['Name', 'Actions']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderResourceRow = (resource: any) => {
|
|
||||||
const metadata = resource.metadata || {}
|
|
||||||
const spec = resource.spec || {}
|
|
||||||
const status = resource.status || {}
|
|
||||||
|
|
||||||
const getAge = (timestamp: string) => {
|
|
||||||
if (!timestamp) return '-'
|
|
||||||
const diff = Date.now() - new Date(timestamp).getTime()
|
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
|
||||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
|
||||||
if (days > 0) return `${days}d`
|
|
||||||
return `${hours}h`
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (selectedKind.key) {
|
|
||||||
case 'namespace':
|
|
||||||
return (
|
|
||||||
<TableRow key={metadata.uid}>
|
|
||||||
<TableCell>{metadata.name || '-'}</TableCell>
|
|
||||||
<TableCell>{status.phase || '-'}</TableCell>
|
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Tooltip title="删除">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
onClick={() => openDeleteDialog(metadata.name, metadata.namespace)}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
case 'deployment':
|
|
||||||
case 'statefulset':
|
|
||||||
return (
|
|
||||||
<TableRow key={metadata.uid}>
|
|
||||||
<TableCell>{metadata.name || '-'}</TableCell>
|
|
||||||
<TableCell>{metadata.namespace || '-'}</TableCell>
|
|
||||||
<TableCell>{`${status.readyReplicas || 0}/${spec.replicas || 0}`}</TableCell>
|
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Tooltip title="编辑">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleEditResource(metadata.name, metadata.namespace, selectedKind.label)}
|
|
||||||
>
|
|
||||||
<EditIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="删除">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
onClick={() => openDeleteDialog(metadata.name, metadata.namespace)}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
case 'service':
|
|
||||||
// Format ports display
|
|
||||||
let portsDisplay = '-';
|
|
||||||
let nodePortsDisplay = '-';
|
|
||||||
if (spec.ports && spec.ports.length > 0) {
|
|
||||||
portsDisplay = spec.ports.map((p: any) => `${p.port}/${p.protocol}`).join(', ');
|
|
||||||
const nodePorts = spec.ports.filter((p: any) => p.nodePort).map((p: any) => p.nodePort);
|
|
||||||
if (nodePorts.length > 0) {
|
|
||||||
nodePortsDisplay = nodePorts.join(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={metadata.uid}>
|
|
||||||
<TableCell>{metadata.name || '-'}</TableCell>
|
|
||||||
<TableCell>{metadata.namespace || '-'}</TableCell>
|
|
||||||
<TableCell>{spec.type || '-'}</TableCell>
|
|
||||||
<TableCell>{spec.clusterIP || '-'}</TableCell>
|
|
||||||
<TableCell>{portsDisplay}</TableCell>
|
|
||||||
<TableCell>{nodePortsDisplay}</TableCell>
|
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Tooltip title="编辑">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleEditResource(metadata.name, metadata.namespace, selectedKind.label)}
|
|
||||||
>
|
|
||||||
<EditIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="删除">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
onClick={() => openDeleteDialog(metadata.name, metadata.namespace)}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
case 'configmap':
|
|
||||||
return (
|
|
||||||
<TableRow key={metadata.uid}>
|
|
||||||
<TableCell>{metadata.name || '-'}</TableCell>
|
|
||||||
<TableCell>{metadata.namespace || '-'}</TableCell>
|
|
||||||
<TableCell>{Object.keys(resource.data || {}).length}</TableCell>
|
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Tooltip title="编辑">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleEditResource(metadata.name, metadata.namespace, selectedKind.label)}
|
|
||||||
>
|
|
||||||
<EditIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="删除">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
onClick={() => openDeleteDialog(metadata.name, metadata.namespace)}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
case 'pod':
|
|
||||||
return (
|
|
||||||
<TableRow key={metadata.uid}>
|
|
||||||
<TableCell>{metadata.name || '-'}</TableCell>
|
|
||||||
<TableCell>{metadata.namespace || '-'}</TableCell>
|
|
||||||
<TableCell>{status.phase || '-'}</TableCell>
|
|
||||||
<TableCell>{status.podIP || '-'}</TableCell>
|
|
||||||
<TableCell>{spec.nodeName || '-'}</TableCell>
|
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleViewLogs(metadata.name, metadata.namespace)}
|
|
||||||
title="查看日志"
|
|
||||||
>
|
|
||||||
<VisibilityIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
onClick={() => openDeleteDialog(metadata.name, metadata.namespace)}
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
case 'pv':
|
|
||||||
return (
|
|
||||||
<TableRow key={metadata.uid}>
|
|
||||||
<TableCell>{metadata.name || '-'}</TableCell>
|
|
||||||
<TableCell>{spec.capacity?.storage || '-'}</TableCell>
|
|
||||||
<TableCell>{spec.accessModes?.join(', ') || '-'}</TableCell>
|
|
||||||
<TableCell>{status.phase || '-'}</TableCell>
|
|
||||||
<TableCell>{spec.claimRef?.name ? `${spec.claimRef.namespace}/${spec.claimRef.name}` : '-'}</TableCell>
|
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
case 'pvc':
|
|
||||||
return (
|
|
||||||
<TableRow key={metadata.uid}>
|
|
||||||
<TableCell>{metadata.name || '-'}</TableCell>
|
|
||||||
<TableCell>{metadata.namespace || '-'}</TableCell>
|
|
||||||
<TableCell>{status.phase || '-'}</TableCell>
|
|
||||||
<TableCell>{spec.volumeName || '-'}</TableCell>
|
|
||||||
<TableCell>{status.capacity?.storage || '-'}</TableCell>
|
|
||||||
<TableCell>{spec.accessModes?.join(', ') || '-'}</TableCell>
|
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<TableRow key={metadata.uid}>
|
|
||||||
<TableCell>{metadata.name || '-'}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex' }}>
|
|
||||||
<Drawer
|
|
||||||
variant="permanent"
|
|
||||||
sx={{
|
|
||||||
width: DRAWER_WIDTH,
|
|
||||||
flexShrink: 0,
|
|
||||||
'& .MuiDrawer-paper': {
|
|
||||||
width: DRAWER_WIDTH,
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<List>
|
|
||||||
{KINDS.map((kind) => (
|
|
||||||
<ListItem key={kind.key} disablePadding>
|
|
||||||
<ListItemButton
|
|
||||||
selected={selectedKind.key === kind.key}
|
|
||||||
onClick={() => setSelectedKind(kind)}
|
|
||||||
>
|
|
||||||
<ListItemText primary={kind.label} />
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h5">{selectedKind.label}</Typography>
|
|
||||||
<Box>
|
|
||||||
<IconButton onClick={() => setCreateDialogOpen(true)} sx={{ mr: 1 }}>
|
|
||||||
<AddIcon />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton onClick={() => setSettingsOpen(true)}>
|
|
||||||
<SettingsIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{selectedKind.key !== 'namespace' && selectedKind.key !== 'pv' && (
|
|
||||||
<Box sx={{ mb: 2, display: 'flex', gap: 2 }}>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="Namespace"
|
|
||||||
value={namespace}
|
|
||||||
onChange={(e) => setNamespace(e.target.value)}
|
|
||||||
size="small"
|
|
||||||
sx={{ width: 200 }}
|
|
||||||
SelectProps={{
|
|
||||||
displayEmpty: true,
|
|
||||||
}}
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
>
|
|
||||||
<MenuItem value="">
|
|
||||||
<em>所有命名空间</em>
|
|
||||||
</MenuItem>
|
|
||||||
{namespaces.map((ns) => (
|
|
||||||
<MenuItem key={ns} value={ns}>
|
|
||||||
{ns}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
|
||||||
label="名称过滤"
|
|
||||||
placeholder="按名称过滤"
|
|
||||||
value={nameFilter}
|
|
||||||
onChange={(e) => setNameFilter(e.target.value)}
|
|
||||||
size="small"
|
|
||||||
sx={{ width: 200 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{kubeconfigError && (
|
|
||||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
|
||||||
请先在右侧设置中配置 Kubeconfig
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && (
|
|
||||||
<TableContainer component={Paper}>
|
|
||||||
<Table>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
{getResourceColumns().map((col) => (
|
|
||||||
<TableCell key={col}>{col}</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{resources.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={getResourceColumns().length} align="center">
|
|
||||||
暂无数据
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{resources.map((resource) => renderResourceRow(resource))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Drawer
|
|
||||||
anchor="right"
|
|
||||||
open={settingsOpen}
|
|
||||||
onClose={() => setSettingsOpen(false)}
|
|
||||||
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={() => setSettingsOpen(false)}>
|
|
||||||
<CloseIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="Kubeconfig"
|
|
||||||
multiline
|
|
||||||
rows={20}
|
|
||||||
value={kubeconfig}
|
|
||||||
onChange={(e) => setKubeconfig(e.target.value)}
|
|
||||||
placeholder="粘贴 kubeconfig 内容..."
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<Button variant="contained" onClick={saveKubeconfig}>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={editDialogOpen}
|
|
||||||
onClose={() => setEditDialogOpen(false)}
|
|
||||||
maxWidth="md"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography variant="h6">
|
|
||||||
编辑 {editResource?.kind}: {editResource?.name} ({editResource?.namespace})
|
|
||||||
</Typography>
|
|
||||||
<IconButton onClick={() => setEditDialogOpen(false)}>
|
|
||||||
<CloseIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
rows={20}
|
|
||||||
value={editYaml}
|
|
||||||
onChange={(e) => setEditYaml(e.target.value)}
|
|
||||||
placeholder="YAML 内容..."
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setEditDialogOpen(false)}>取消</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleApplyEdit}
|
|
||||||
disabled={editing}
|
|
||||||
>
|
|
||||||
{editing ? <CircularProgress size={24} /> : '应用'}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={createDialogOpen}
|
|
||||||
onClose={() => setCreateDialogOpen(false)}
|
|
||||||
maxWidth="md"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography variant="h6">创建资源</Typography>
|
|
||||||
<IconButton onClick={() => setCreateDialogOpen(false)}>
|
|
||||||
<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) => setYamlContent(e.target.value)}
|
|
||||||
placeholder="粘贴或编辑 YAML 内容..."
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setCreateDialogOpen(false)}>取消</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleApplyYaml}
|
|
||||||
disabled={applyLoading}
|
|
||||||
>
|
|
||||||
{applyLoading ? <CircularProgress size={24} /> : '应用'}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={logsDialogOpen}
|
|
||||||
onClose={handleCloseLogsDialog}
|
|
||||||
maxWidth="lg"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography variant="h6">
|
|
||||||
Pod 日志: {selectedPod?.name} ({selectedPod?.namespace})
|
|
||||||
</Typography>
|
|
||||||
<IconButton onClick={handleCloseLogsDialog}>
|
|
||||||
<CloseIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
bgcolor: '#1e1e1e',
|
|
||||||
color: '#d4d4d4',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
maxHeight: '60vh',
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{logs.length === 0 && <Typography>等待日志...</Typography>}
|
|
||||||
{logs.map((log, index) => (
|
|
||||||
<Box key={index} sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
||||||
{log}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
<div ref={logsEndRef} />
|
|
||||||
</Paper>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
|
||||||
<DialogTitle>确认删除</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Typography>
|
|
||||||
确定要删除 {selectedKind.label} <strong>{deleteTarget?.name}</strong>
|
|
||||||
{deleteTarget?.namespace && selectedKind.key !== 'namespace' ? ` (namespace: ${deleteTarget?.namespace})` : ''} 吗?
|
|
||||||
</Typography>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="error"
|
|
||||||
onClick={handleDeleteResource}
|
|
||||||
disabled={deleting}
|
|
||||||
>
|
|
||||||
{deleting ? <CircularProgress size={24} /> : '删除'}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Snackbar
|
|
||||||
open={snackbar.open}
|
|
||||||
autoHideDuration={6000}
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
|
||||||
>
|
|
||||||
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
|
||||||
{snackbar.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
323
frontend/src/pages/k8s/ConfigMapPage.tsx
Normal file
323
frontend/src/pages/k8s/ConfigMapPage.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Tooltip,
|
||||||
|
Snackbar,
|
||||||
|
} from '@mui/material'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import { useNamespaces } from '../../hooks/useNamespaces'
|
||||||
|
import { getAge } from '../../utils/k8sHelpers'
|
||||||
|
import CreateConfigMapDialog from '../../components/CreateConfigMapDialog'
|
||||||
|
import DeleteConfirmDialog from '../../components/k8s/DeleteConfirmDialog'
|
||||||
|
import EditResourceDialog from '../../components/k8s/EditResourceDialog'
|
||||||
|
|
||||||
|
export default function ConfigMapPage() {
|
||||||
|
const [resources, setResources] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [namespace, setNamespace] = useState('')
|
||||||
|
const [nameFilter, setNameFilter] = useState('')
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
|
const [editResource, setEditResource] = useState<{ name: string; namespace: string; yaml: string } | null>(null)
|
||||||
|
const [editYaml, setEditYaml] = useState('')
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { namespaces } = useNamespaces()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResources()
|
||||||
|
}, [namespace, nameFilter])
|
||||||
|
|
||||||
|
const fetchResources = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (namespace) params.append('namespace', namespace)
|
||||||
|
if (nameFilter) params.append('name', nameFilter)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/k8s/configmap/list?${params}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resources')
|
||||||
|
}
|
||||||
|
|
||||||
|
setResources(result.data?.items || [])
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateConfigMap = async (name: string, ns: string, data: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/configmap/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, namespace: ns, data }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to create ConfigMap')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'ConfigMap 创建成功', severity: 'success' })
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `创建失败: ${e.message}`, severity: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteResource = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/configmap/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: deleteTarget.name,
|
||||||
|
namespace: deleteTarget.namespace,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to delete ConfigMap')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'ConfigMap 删除成功', severity: 'success' })
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `删除失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditResource = async (name: string, ns: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/k8s/resource/get?kind=ConfigMap&name=${name}&namespace=${ns}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditResource({ name, namespace: ns, yaml: result.data?.yaml || '' })
|
||||||
|
setEditYaml(result.data?.yaml || '')
|
||||||
|
setEditDialogOpen(true)
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `获取资源失败: ${e.message}`, severity: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEdit = async (yaml: string) => {
|
||||||
|
if (!editResource) return
|
||||||
|
|
||||||
|
setEditing(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to update resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: '更新成功', severity: 'success' })
|
||||||
|
setEditDialogOpen(false)
|
||||||
|
setEditResource(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `更新失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">ConfigMap</Typography>
|
||||||
|
<IconButton onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Namespace"
|
||||||
|
value={namespace}
|
||||||
|
onChange={(e) => setNamespace(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
SelectProps={{
|
||||||
|
displayEmpty: true,
|
||||||
|
}}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>所有命名空间</em>
|
||||||
|
</MenuItem>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<MenuItem key={ns} value={ns}>
|
||||||
|
{ns}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="名称过滤"
|
||||||
|
placeholder="按名称过滤"
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Namespace</TableCell>
|
||||||
|
<TableCell>Data Keys</TableCell>
|
||||||
|
<TableCell>Age</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{resources.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} align="center">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{resources.map((resource) => {
|
||||||
|
const metadata = resource.metadata || {}
|
||||||
|
return (
|
||||||
|
<TableRow key={metadata.uid}>
|
||||||
|
<TableCell>{metadata.name || '-'}</TableCell>
|
||||||
|
<TableCell>{metadata.namespace || '-'}</TableCell>
|
||||||
|
<TableCell>{Object.keys(resource.data || {}).length}</TableCell>
|
||||||
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEditResource(metadata.name, metadata.namespace)}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget({ name: metadata.name, namespace: metadata.namespace })
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateConfigMapDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
onSubmit={handleCreateConfigMap}
|
||||||
|
namespaces={namespaces}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
onConfirm={handleDeleteResource}
|
||||||
|
resourceType="ConfigMap"
|
||||||
|
resourceName={deleteTarget?.name || ''}
|
||||||
|
namespace={deleteTarget?.namespace}
|
||||||
|
deleting={deleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditResourceDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
resourceType="ConfigMap"
|
||||||
|
resourceName={editResource?.name || ''}
|
||||||
|
namespace={editResource?.namespace || ''}
|
||||||
|
yaml={editYaml}
|
||||||
|
onYamlChange={setEditYaml}
|
||||||
|
saving={editing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
339
frontend/src/pages/k8s/DeploymentPage.tsx
Normal file
339
frontend/src/pages/k8s/DeploymentPage.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Tooltip,
|
||||||
|
Snackbar,
|
||||||
|
} from '@mui/material'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import { useNamespaces } from '../../hooks/useNamespaces'
|
||||||
|
import { getAge } from '../../utils/k8sHelpers'
|
||||||
|
import CreateYamlDialog from '../../components/k8s/CreateYamlDialog'
|
||||||
|
import DeleteConfirmDialog from '../../components/k8s/DeleteConfirmDialog'
|
||||||
|
import EditResourceDialog from '../../components/k8s/EditResourceDialog'
|
||||||
|
|
||||||
|
export default function DeploymentPage() {
|
||||||
|
const [resources, setResources] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [namespace, setNamespace] = useState('')
|
||||||
|
const [nameFilter, setNameFilter] = useState('')
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [yamlContent, setYamlContent] = useState('')
|
||||||
|
const [applyLoading, setApplyLoading] = useState(false)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
|
const [editResource, setEditResource] = useState<{ name: string; namespace: string; yaml: string } | null>(null)
|
||||||
|
const [editYaml, setEditYaml] = useState('')
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { namespaces } = useNamespaces()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResources()
|
||||||
|
}, [namespace, nameFilter])
|
||||||
|
|
||||||
|
const fetchResources = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (namespace) params.append('namespace', namespace)
|
||||||
|
if (nameFilter) params.append('name', nameFilter)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/k8s/deployment/list?${params}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resources')
|
||||||
|
}
|
||||||
|
|
||||||
|
setResources(result.data?.items || [])
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyYaml = async (yaml: string) => {
|
||||||
|
if (!yaml.trim()) {
|
||||||
|
setSnackbar({ open: true, message: 'YAML 内容不能为空', severity: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setApplyLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to apply resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: '资源应用成功', severity: 'success' })
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
setYamlContent('')
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `应用失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setApplyLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteResource = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/deployment/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: deleteTarget.name,
|
||||||
|
namespace: deleteTarget.namespace,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to delete Deployment')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'Deployment 删除成功', severity: 'success' })
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `删除失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditResource = async (name: string, ns: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/k8s/resource/get?kind=Deployment&name=${name}&namespace=${ns}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditResource({ name, namespace: ns, yaml: result.data?.yaml || '' })
|
||||||
|
setEditYaml(result.data?.yaml || '')
|
||||||
|
setEditDialogOpen(true)
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `获取资源失败: ${e.message}`, severity: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEdit = async (yaml: string) => {
|
||||||
|
if (!editResource) return
|
||||||
|
|
||||||
|
setEditing(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to update resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: '更新成功', severity: 'success' })
|
||||||
|
setEditDialogOpen(false)
|
||||||
|
setEditResource(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `更新失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">Deployment</Typography>
|
||||||
|
<IconButton onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Namespace"
|
||||||
|
value={namespace}
|
||||||
|
onChange={(e) => setNamespace(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
SelectProps={{
|
||||||
|
displayEmpty: true,
|
||||||
|
}}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>所有命名空间</em>
|
||||||
|
</MenuItem>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<MenuItem key={ns} value={ns}>
|
||||||
|
{ns}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="名称过滤"
|
||||||
|
placeholder="按名称过滤"
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Namespace</TableCell>
|
||||||
|
<TableCell>Replicas</TableCell>
|
||||||
|
<TableCell>Age</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{resources.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} align="center">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{resources.map((resource) => {
|
||||||
|
const metadata = resource.metadata || {}
|
||||||
|
const spec = resource.spec || {}
|
||||||
|
const status = resource.status || {}
|
||||||
|
return (
|
||||||
|
<TableRow key={metadata.uid}>
|
||||||
|
<TableCell>{metadata.name || '-'}</TableCell>
|
||||||
|
<TableCell>{metadata.namespace || '-'}</TableCell>
|
||||||
|
<TableCell>{`${status.readyReplicas || 0}/${spec.replicas || 0}`}</TableCell>
|
||||||
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEditResource(metadata.name, metadata.namespace)}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget({ name: metadata.name, namespace: metadata.namespace })
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateYamlDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
onApply={handleApplyYaml}
|
||||||
|
yamlContent={yamlContent}
|
||||||
|
onYamlChange={setYamlContent}
|
||||||
|
loading={applyLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
onConfirm={handleDeleteResource}
|
||||||
|
resourceType="Deployment"
|
||||||
|
resourceName={deleteTarget?.name || ''}
|
||||||
|
namespace={deleteTarget?.namespace}
|
||||||
|
deleting={deleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditResourceDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
resourceType="Deployment"
|
||||||
|
resourceName={editResource?.name || ''}
|
||||||
|
namespace={editResource?.namespace || ''}
|
||||||
|
yaml={editYaml}
|
||||||
|
onYamlChange={setEditYaml}
|
||||||
|
saving={editing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
frontend/src/pages/k8s/K8sLayout.tsx
Normal file
78
frontend/src/pages/k8s/K8sLayout.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Drawer,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material'
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { RESOURCE_KINDS, DRAWER_WIDTH } from '../../utils/k8sConstants'
|
||||||
|
import KubeconfigSettingsDrawer from '../../components/k8s/KubeconfigSettingsDrawer'
|
||||||
|
import { useKubeconfig } from '../../hooks/useKubeconfig'
|
||||||
|
|
||||||
|
export default function K8sLayout() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const { kubeconfig, setKubeconfig, saveKubeconfig } = useKubeconfig()
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await saveKubeconfig(kubeconfig)
|
||||||
|
setSettingsOpen(false)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save kubeconfig:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', p: 1 }}>
|
||||||
|
<IconButton onClick={() => setSettingsOpen(true)}>
|
||||||
|
<SettingsIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<List>
|
||||||
|
{RESOURCE_KINDS.map((kind) => (
|
||||||
|
<ListItem key={kind.key} disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
selected={location.pathname === kind.path}
|
||||||
|
onClick={() => navigate(kind.path)}
|
||||||
|
>
|
||||||
|
<ListItemText primary={kind.label} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<KubeconfigSettingsDrawer
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
kubeconfig={kubeconfig}
|
||||||
|
onKubeconfigChange={setKubeconfig}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
226
frontend/src/pages/k8s/NamespacePage.tsx
Normal file
226
frontend/src/pages/k8s/NamespacePage.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Snackbar,
|
||||||
|
} from '@mui/material'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import { getAge } from '../../utils/k8sHelpers'
|
||||||
|
import CreateNamespaceDialog from '../../components/k8s/CreateNamespaceDialog'
|
||||||
|
import DeleteConfirmDialog from '../../components/k8s/DeleteConfirmDialog'
|
||||||
|
|
||||||
|
export default function NamespacePage() {
|
||||||
|
const [resources, setResources] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [nameFilter, setNameFilter] = useState('')
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ name: string } | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResources()
|
||||||
|
}, [nameFilter])
|
||||||
|
|
||||||
|
const fetchResources = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (nameFilter) params.append('name', nameFilter)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/k8s/namespace/list?${params}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resources')
|
||||||
|
}
|
||||||
|
|
||||||
|
setResources(result.data?.items || [])
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateNamespace = async (name: string) => {
|
||||||
|
try {
|
||||||
|
const yaml = `apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: ${name}`
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to create Namespace')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'Namespace 创建成功', severity: 'success' })
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `创建失败: ${e.message}`, severity: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteResource = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/namespace/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: deleteTarget.name,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to delete Namespace')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'Namespace 删除成功', severity: 'success' })
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `删除失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">Namespace</Typography>
|
||||||
|
<IconButton onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="名称过滤"
|
||||||
|
placeholder="按名称过滤"
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Age</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{resources.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} align="center">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{resources.map((resource) => {
|
||||||
|
const metadata = resource.metadata || {}
|
||||||
|
const status = resource.status || {}
|
||||||
|
return (
|
||||||
|
<TableRow key={metadata.uid}>
|
||||||
|
<TableCell>{metadata.name || '-'}</TableCell>
|
||||||
|
<TableCell>{status.phase || '-'}</TableCell>
|
||||||
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget({ name: metadata.name })
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateNamespaceDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
onSubmit={handleCreateNamespace}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
onConfirm={handleDeleteResource}
|
||||||
|
resourceType="Namespace"
|
||||||
|
resourceName={deleteTarget?.name || ''}
|
||||||
|
deleting={deleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
279
frontend/src/pages/k8s/PVCPage.tsx
Normal file
279
frontend/src/pages/k8s/PVCPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Tooltip,
|
||||||
|
Snackbar,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import { useNamespaces } from '../../hooks/useNamespaces'
|
||||||
|
import { getAge } from '../../utils/k8sHelpers'
|
||||||
|
import CreateYamlDialog from '../../components/k8s/CreateYamlDialog'
|
||||||
|
import DeleteConfirmDialog from '../../components/k8s/DeleteConfirmDialog'
|
||||||
|
|
||||||
|
export default function PVCPage() {
|
||||||
|
const [resources, setResources] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [namespace, setNamespace] = useState('')
|
||||||
|
const [nameFilter, setNameFilter] = useState('')
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [yamlContent, setYamlContent] = useState('')
|
||||||
|
const [applyLoading, setApplyLoading] = useState(false)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { namespaces } = useNamespaces()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResources()
|
||||||
|
}, [namespace, nameFilter])
|
||||||
|
|
||||||
|
const fetchResources = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (namespace) params.append('namespace', namespace)
|
||||||
|
if (nameFilter) params.append('name', nameFilter)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/k8s/pvc/list?${params}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resources')
|
||||||
|
}
|
||||||
|
|
||||||
|
setResources(result.data?.items || [])
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyYaml = async (yaml: string) => {
|
||||||
|
if (!yaml.trim()) {
|
||||||
|
setSnackbar({ open: true, message: 'YAML 内容不能为空', severity: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setApplyLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to apply resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'PersistentVolumeClaim 应用成功', severity: 'success' })
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
setYamlContent('')
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `应用失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setApplyLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteResource = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/pvc/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: deleteTarget.name,
|
||||||
|
namespace: deleteTarget.namespace,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to delete PersistentVolumeClaim')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'PersistentVolumeClaim 删除成功', severity: 'success' })
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `删除失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">PersistentVolumeClaim</Typography>
|
||||||
|
<IconButton onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Namespace"
|
||||||
|
value={namespace}
|
||||||
|
onChange={(e) => setNamespace(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
SelectProps={{
|
||||||
|
displayEmpty: true,
|
||||||
|
}}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>所有命名空间</em>
|
||||||
|
</MenuItem>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<MenuItem key={ns} value={ns}>
|
||||||
|
{ns}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="名称过滤"
|
||||||
|
placeholder="按名称过滤"
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Namespace</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Volume</TableCell>
|
||||||
|
<TableCell>Capacity</TableCell>
|
||||||
|
<TableCell>Access Modes</TableCell>
|
||||||
|
<TableCell>Age</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{resources.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{resources.map((resource) => {
|
||||||
|
const metadata = resource.metadata || {}
|
||||||
|
const spec = resource.spec || {}
|
||||||
|
const status = resource.status || {}
|
||||||
|
return (
|
||||||
|
<TableRow key={metadata.uid}>
|
||||||
|
<TableCell>{metadata.name || '-'}</TableCell>
|
||||||
|
<TableCell>{metadata.namespace || '-'}</TableCell>
|
||||||
|
<TableCell>{status.phase || '-'}</TableCell>
|
||||||
|
<TableCell>{spec.volumeName || '-'}</TableCell>
|
||||||
|
<TableCell>{status.capacity?.storage || '-'}</TableCell>
|
||||||
|
<TableCell>{spec.accessModes?.join(', ') || '-'}</TableCell>
|
||||||
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget({ name: metadata.name, namespace: metadata.namespace })
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateYamlDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
onApply={handleApplyYaml}
|
||||||
|
yamlContent={yamlContent}
|
||||||
|
onYamlChange={setYamlContent}
|
||||||
|
loading={applyLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
onConfirm={handleDeleteResource}
|
||||||
|
resourceType="PersistentVolumeClaim"
|
||||||
|
resourceName={deleteTarget?.name || ''}
|
||||||
|
namespace={deleteTarget?.namespace}
|
||||||
|
deleting={deleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
249
frontend/src/pages/k8s/PVPage.tsx
Normal file
249
frontend/src/pages/k8s/PVPage.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Snackbar,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import { getAge } from '../../utils/k8sHelpers'
|
||||||
|
import CreateYamlDialog from '../../components/k8s/CreateYamlDialog'
|
||||||
|
import DeleteConfirmDialog from '../../components/k8s/DeleteConfirmDialog'
|
||||||
|
|
||||||
|
export default function PVPage() {
|
||||||
|
const [resources, setResources] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [nameFilter, setNameFilter] = useState('')
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [yamlContent, setYamlContent] = useState('')
|
||||||
|
const [applyLoading, setApplyLoading] = useState(false)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ name: string } | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResources()
|
||||||
|
}, [nameFilter])
|
||||||
|
|
||||||
|
const fetchResources = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (nameFilter) params.append('name', nameFilter)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/k8s/pv/list?${params}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resources')
|
||||||
|
}
|
||||||
|
|
||||||
|
setResources(result.data?.items || [])
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyYaml = async (yaml: string) => {
|
||||||
|
if (!yaml.trim()) {
|
||||||
|
setSnackbar({ open: true, message: 'YAML 内容不能为空', severity: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setApplyLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to apply resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'PersistentVolume 应用成功', severity: 'success' })
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
setYamlContent('')
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `应用失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setApplyLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteResource = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
// Note: PV deletion might need special handling in backend
|
||||||
|
const res = await fetch('/api/v1/k8s/pv/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: deleteTarget.name,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to delete PersistentVolume')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'PersistentVolume 删除成功', severity: 'success' })
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `删除失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">PersistentVolume</Typography>
|
||||||
|
<IconButton onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="名称过滤"
|
||||||
|
placeholder="按名称过滤"
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Capacity</TableCell>
|
||||||
|
<TableCell>Access Modes</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Claim</TableCell>
|
||||||
|
<TableCell>Age</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{resources.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{resources.map((resource) => {
|
||||||
|
const metadata = resource.metadata || {}
|
||||||
|
const spec = resource.spec || {}
|
||||||
|
const status = resource.status || {}
|
||||||
|
return (
|
||||||
|
<TableRow key={metadata.uid}>
|
||||||
|
<TableCell>{metadata.name || '-'}</TableCell>
|
||||||
|
<TableCell>{spec.capacity?.storage || '-'}</TableCell>
|
||||||
|
<TableCell>{spec.accessModes?.join(', ') || '-'}</TableCell>
|
||||||
|
<TableCell>{status.phase || '-'}</TableCell>
|
||||||
|
<TableCell>{spec.claimRef?.name ? `${spec.claimRef.namespace}/${spec.claimRef.name}` : '-'}</TableCell>
|
||||||
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget({ name: metadata.name })
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateYamlDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
onApply={handleApplyYaml}
|
||||||
|
yamlContent={yamlContent}
|
||||||
|
onYamlChange={setYamlContent}
|
||||||
|
loading={applyLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
onConfirm={handleDeleteResource}
|
||||||
|
resourceType="PersistentVolume"
|
||||||
|
resourceName={deleteTarget?.name || ''}
|
||||||
|
deleting={deleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
264
frontend/src/pages/k8s/PodPage.tsx
Normal file
264
frontend/src/pages/k8s/PodPage.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Tooltip,
|
||||||
|
Snackbar,
|
||||||
|
} from '@mui/material'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility'
|
||||||
|
import { useNamespaces } from '../../hooks/useNamespaces'
|
||||||
|
import { getAge } from '../../utils/k8sHelpers'
|
||||||
|
import DeleteConfirmDialog from '../../components/k8s/DeleteConfirmDialog'
|
||||||
|
import PodLogsDialog from '../../components/k8s/PodLogsDialog'
|
||||||
|
|
||||||
|
export default function PodPage() {
|
||||||
|
const [resources, setResources] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [namespace, setNamespace] = useState('')
|
||||||
|
const [nameFilter, setNameFilter] = useState('')
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success',
|
||||||
|
})
|
||||||
|
const [logsDialogOpen, setLogsDialogOpen] = useState(false)
|
||||||
|
const [logs, setLogs] = useState<string[]>([])
|
||||||
|
const [selectedPod, setSelectedPod] = useState<{ name: string; namespace: string } | null>(null)
|
||||||
|
|
||||||
|
const { namespaces } = useNamespaces()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResources()
|
||||||
|
}, [namespace, nameFilter])
|
||||||
|
|
||||||
|
const fetchResources = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (namespace) params.append('namespace', namespace)
|
||||||
|
if (nameFilter) params.append('name', nameFilter)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/k8s/pod/list?${params}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resources')
|
||||||
|
}
|
||||||
|
|
||||||
|
setResources(result.data?.items || [])
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteResource = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/pod/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: deleteTarget.name,
|
||||||
|
namespace: deleteTarget.namespace,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to delete Pod')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'Pod 删除成功', severity: 'success' })
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `删除失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewLogs = async (podName: string, podNamespace: string) => {
|
||||||
|
setSelectedPod({ name: podName, namespace: podNamespace })
|
||||||
|
setLogs([])
|
||||||
|
setLogsDialogOpen(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/k8s/pod/logs?name=${encodeURIComponent(podName)}&namespace=${encodeURIComponent(podNamespace)}&tail=1000`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch logs')
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogs(result.data?.logs || [])
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `获取日志失败: ${e.message}`, severity: 'error' })
|
||||||
|
setLogsDialogOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">Pod</Typography>
|
||||||
|
{/* No create button for Pods */}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Namespace"
|
||||||
|
value={namespace}
|
||||||
|
onChange={(e) => setNamespace(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
SelectProps={{
|
||||||
|
displayEmpty: true,
|
||||||
|
}}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>所有命名空间</em>
|
||||||
|
</MenuItem>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<MenuItem key={ns} value={ns}>
|
||||||
|
{ns}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="名称过滤"
|
||||||
|
placeholder="按名称过滤"
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Namespace</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>IP</TableCell>
|
||||||
|
<TableCell>Node</TableCell>
|
||||||
|
<TableCell>Age</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{resources.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{resources.map((resource) => {
|
||||||
|
const metadata = resource.metadata || {}
|
||||||
|
const spec = resource.spec || {}
|
||||||
|
const status = resource.status || {}
|
||||||
|
return (
|
||||||
|
<TableRow key={metadata.uid}>
|
||||||
|
<TableCell>{metadata.name || '-'}</TableCell>
|
||||||
|
<TableCell>{metadata.namespace || '-'}</TableCell>
|
||||||
|
<TableCell>{status.phase || '-'}</TableCell>
|
||||||
|
<TableCell>{status.podIP || '-'}</TableCell>
|
||||||
|
<TableCell>{spec.nodeName || '-'}</TableCell>
|
||||||
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleViewLogs(metadata.name, metadata.namespace)}
|
||||||
|
title="查看日志"
|
||||||
|
>
|
||||||
|
<VisibilityIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget({ name: metadata.name, namespace: metadata.namespace })
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
onConfirm={handleDeleteResource}
|
||||||
|
resourceType="Pod"
|
||||||
|
resourceName={deleteTarget?.name || ''}
|
||||||
|
namespace={deleteTarget?.namespace}
|
||||||
|
deleting={deleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PodLogsDialog
|
||||||
|
open={logsDialogOpen}
|
||||||
|
onClose={() => setLogsDialogOpen(false)}
|
||||||
|
podName={selectedPod?.name || ''}
|
||||||
|
namespace={selectedPod?.namespace || ''}
|
||||||
|
logs={logs}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
380
frontend/src/pages/k8s/ServicePage.tsx
Normal file
380
frontend/src/pages/k8s/ServicePage.tsx
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Tooltip,
|
||||||
|
Snackbar,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import { useNamespaces } from '../../hooks/useNamespaces'
|
||||||
|
import { getAge } from '../../utils/k8sHelpers'
|
||||||
|
import CreateYamlDialog from '../../components/k8s/CreateYamlDialog'
|
||||||
|
import DeleteConfirmDialog from '../../components/k8s/DeleteConfirmDialog'
|
||||||
|
import EditResourceDialog from '../../components/k8s/EditResourceDialog'
|
||||||
|
|
||||||
|
export default function ServicePage() {
|
||||||
|
const [resources, setResources] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [namespace, setNamespace] = useState('')
|
||||||
|
const [nameFilter, setNameFilter] = useState('')
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [yamlContent, setYamlContent] = useState('')
|
||||||
|
const [applyLoading, setApplyLoading] = useState(false)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
|
const [editResource, setEditResource] = useState<{ name: string; namespace: string; yaml: string } | null>(null)
|
||||||
|
const [editYaml, setEditYaml] = useState('')
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success',
|
||||||
|
})
|
||||||
|
const [featureDialogOpen, setFeatureDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
const { namespaces } = useNamespaces()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResources()
|
||||||
|
}, [namespace, nameFilter])
|
||||||
|
|
||||||
|
const fetchResources = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (namespace) params.append('namespace', namespace)
|
||||||
|
if (nameFilter) params.append('name', nameFilter)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/k8s/service/list?${params}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resources')
|
||||||
|
}
|
||||||
|
|
||||||
|
setResources(result.data?.items || [])
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateClick = () => {
|
||||||
|
setFeatureDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyYaml = async (yaml: string) => {
|
||||||
|
if (!yaml.trim()) {
|
||||||
|
setSnackbar({ open: true, message: 'YAML 内容不能为空', severity: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setApplyLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to apply resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'Service 应用成功', severity: 'success' })
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
setYamlContent('')
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `应用失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setApplyLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteResource = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/service/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: deleteTarget.name,
|
||||||
|
namespace: deleteTarget.namespace,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to delete Service')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'Service 删除成功', severity: 'success' })
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `删除失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditResource = async (name: string, ns: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/k8s/resource/get?kind=Service&name=${name}&namespace=${ns}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditResource({ name, namespace: ns, yaml: result.data?.yaml || '' })
|
||||||
|
setEditYaml(result.data?.yaml || '')
|
||||||
|
setEditDialogOpen(true)
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `获取资源失败: ${e.message}`, severity: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEdit = async (yaml: string) => {
|
||||||
|
if (!editResource) return
|
||||||
|
|
||||||
|
setEditing(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to update resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: '更新成功', severity: 'success' })
|
||||||
|
setEditDialogOpen(false)
|
||||||
|
setEditResource(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `更新失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">Service</Typography>
|
||||||
|
<IconButton onClick={handleCreateClick}>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Namespace"
|
||||||
|
value={namespace}
|
||||||
|
onChange={(e) => setNamespace(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
SelectProps={{
|
||||||
|
displayEmpty: true,
|
||||||
|
}}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>所有命名空间</em>
|
||||||
|
</MenuItem>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<MenuItem key={ns} value={ns}>
|
||||||
|
{ns}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="名称过滤"
|
||||||
|
placeholder="按名称过滤"
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Namespace</TableCell>
|
||||||
|
<TableCell>Type</TableCell>
|
||||||
|
<TableCell>Cluster IP</TableCell>
|
||||||
|
<TableCell>Ports</TableCell>
|
||||||
|
<TableCell>NodePort</TableCell>
|
||||||
|
<TableCell>Age</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{resources.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{resources.map((resource) => {
|
||||||
|
const metadata = resource.metadata || {}
|
||||||
|
const spec = resource.spec || {}
|
||||||
|
const status = resource.status || {}
|
||||||
|
|
||||||
|
// Format ports display
|
||||||
|
let portsDisplay = '-';
|
||||||
|
let nodePortsDisplay = '-';
|
||||||
|
if (spec.ports && spec.ports.length > 0) {
|
||||||
|
portsDisplay = spec.ports.map((p: any) => `${p.port}/${p.protocol}`).join(', ')
|
||||||
|
const nodePorts = spec.ports.filter((p: any) => p.nodePort).map((p: any) => p.nodePort)
|
||||||
|
if (nodePorts.length > 0) {
|
||||||
|
nodePortsDisplay = nodePorts.join(', ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={metadata.uid}>
|
||||||
|
<TableCell>{metadata.name || '-'}</TableCell>
|
||||||
|
<TableCell>{metadata.namespace || '-'}</TableCell>
|
||||||
|
<TableCell>{spec.type || '-'}</TableCell>
|
||||||
|
<TableCell>{spec.clusterIP || '-'}</TableCell>
|
||||||
|
<TableCell>{portsDisplay}</TableCell>
|
||||||
|
<TableCell>{nodePortsDisplay}</TableCell>
|
||||||
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEditResource(metadata.name, metadata.namespace)}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget({ name: metadata.name, namespace: metadata.namespace })
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feature Coming Soon Dialog */}
|
||||||
|
<Dialog open={featureDialogOpen} onClose={() => setFeatureDialogOpen(false)}>
|
||||||
|
<DialogTitle>功能开发中</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Service 创建功能正在开发中,请通过 YAML 文件方式创建。
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setFeatureDialogOpen(false)}>确定</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<CreateYamlDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
onApply={handleApplyYaml}
|
||||||
|
yamlContent={yamlContent}
|
||||||
|
onYamlChange={setYamlContent}
|
||||||
|
loading={applyLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
onConfirm={handleDeleteResource}
|
||||||
|
resourceType="Service"
|
||||||
|
resourceName={deleteTarget?.name || ''}
|
||||||
|
namespace={deleteTarget?.namespace}
|
||||||
|
deleting={deleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditResourceDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
resourceType="Service"
|
||||||
|
resourceName={editResource?.name || ''}
|
||||||
|
namespace={editResource?.namespace || ''}
|
||||||
|
yaml={editYaml}
|
||||||
|
onYamlChange={setEditYaml}
|
||||||
|
saving={editing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
339
frontend/src/pages/k8s/StatefulSetPage.tsx
Normal file
339
frontend/src/pages/k8s/StatefulSetPage.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Tooltip,
|
||||||
|
Snackbar,
|
||||||
|
} from '@mui/material'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import { useNamespaces } from '../../hooks/useNamespaces'
|
||||||
|
import { getAge } from '../../utils/k8sHelpers'
|
||||||
|
import CreateYamlDialog from '../../components/k8s/CreateYamlDialog'
|
||||||
|
import DeleteConfirmDialog from '../../components/k8s/DeleteConfirmDialog'
|
||||||
|
import EditResourceDialog from '../../components/k8s/EditResourceDialog'
|
||||||
|
|
||||||
|
export default function StatefulSetPage() {
|
||||||
|
const [resources, setResources] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [namespace, setNamespace] = useState('')
|
||||||
|
const [nameFilter, setNameFilter] = useState('')
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [yamlContent, setYamlContent] = useState('')
|
||||||
|
const [applyLoading, setApplyLoading] = useState(false)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
|
const [editResource, setEditResource] = useState<{ name: string; namespace: string; yaml: string } | null>(null)
|
||||||
|
const [editYaml, setEditYaml] = useState('')
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { namespaces } = useNamespaces()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResources()
|
||||||
|
}, [namespace, nameFilter])
|
||||||
|
|
||||||
|
const fetchResources = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (namespace) params.append('namespace', namespace)
|
||||||
|
if (nameFilter) params.append('name', nameFilter)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/k8s/statefulset/list?${params}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resources')
|
||||||
|
}
|
||||||
|
|
||||||
|
setResources(result.data?.items || [])
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyYaml = async (yaml: string) => {
|
||||||
|
if (!yaml.trim()) {
|
||||||
|
setSnackbar({ open: true, message: 'YAML 内容不能为空', severity: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setApplyLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to apply resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'StatefulSet 应用成功', severity: 'success' })
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
setYamlContent('')
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `应用失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setApplyLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteResource = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/statefulset/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: deleteTarget.name,
|
||||||
|
namespace: deleteTarget.namespace,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to delete StatefulSet')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'StatefulSet 删除成功', severity: 'success' })
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `删除失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditResource = async (name: string, ns: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/k8s/resource/get?kind=StatefulSet&name=${name}&namespace=${ns}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to fetch resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditResource({ name, namespace: ns, yaml: result.data?.yaml || '' })
|
||||||
|
setEditYaml(result.data?.yaml || '')
|
||||||
|
setEditDialogOpen(true)
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `获取资源失败: ${e.message}`, severity: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEdit = async (yaml: string) => {
|
||||||
|
if (!editResource) return
|
||||||
|
|
||||||
|
setEditing(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to update resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: '更新成功', severity: 'success' })
|
||||||
|
setEditDialogOpen(false)
|
||||||
|
setEditResource(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `更新失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">StatefulSet</Typography>
|
||||||
|
<IconButton onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Namespace"
|
||||||
|
value={namespace}
|
||||||
|
onChange={(e) => setNamespace(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
SelectProps={{
|
||||||
|
displayEmpty: true,
|
||||||
|
}}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>所有命名空间</em>
|
||||||
|
</MenuItem>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<MenuItem key={ns} value={ns}>
|
||||||
|
{ns}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="名称过滤"
|
||||||
|
placeholder="按名称过滤"
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Namespace</TableCell>
|
||||||
|
<TableCell>Replicas</TableCell>
|
||||||
|
<TableCell>Age</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{resources.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} align="center">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{resources.map((resource) => {
|
||||||
|
const metadata = resource.metadata || {}
|
||||||
|
const spec = resource.spec || {}
|
||||||
|
const status = resource.status || {}
|
||||||
|
return (
|
||||||
|
<TableRow key={metadata.uid}>
|
||||||
|
<TableCell>{metadata.name || '-'}</TableCell>
|
||||||
|
<TableCell>{metadata.namespace || '-'}</TableCell>
|
||||||
|
<TableCell>{`${status.readyReplicas || 0}/${spec.replicas || 0}`}</TableCell>
|
||||||
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEditResource(metadata.name, metadata.namespace)}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget({ name: metadata.name, namespace: metadata.namespace })
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateYamlDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
onApply={handleApplyYaml}
|
||||||
|
yamlContent={yamlContent}
|
||||||
|
onYamlChange={setYamlContent}
|
||||||
|
loading={applyLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
onConfirm={handleDeleteResource}
|
||||||
|
resourceType="StatefulSet"
|
||||||
|
resourceName={deleteTarget?.name || ''}
|
||||||
|
namespace={deleteTarget?.namespace}
|
||||||
|
deleting={deleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditResourceDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
resourceType="StatefulSet"
|
||||||
|
resourceName={editResource?.name || ''}
|
||||||
|
namespace={editResource?.namespace || ''}
|
||||||
|
yaml={editYaml}
|
||||||
|
onYamlChange={setEditYaml}
|
||||||
|
saving={editing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
frontend/src/utils/k8sConstants.ts
Normal file
59
frontend/src/utils/k8sConstants.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export interface ResourceKind {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
endpoint: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESOURCE_KINDS: ResourceKind[] = [
|
||||||
|
{
|
||||||
|
key: 'namespace',
|
||||||
|
label: 'Namespace',
|
||||||
|
endpoint: '/api/v1/k8s/namespace/list',
|
||||||
|
path: '/k8s/namespace'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deployment',
|
||||||
|
label: 'Deployment',
|
||||||
|
endpoint: '/api/v1/k8s/deployment/list',
|
||||||
|
path: '/k8s/deployment'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'statefulset',
|
||||||
|
label: 'StatefulSet',
|
||||||
|
endpoint: '/api/v1/k8s/statefulset/list',
|
||||||
|
path: '/k8s/statefulset'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'service',
|
||||||
|
label: 'Service',
|
||||||
|
endpoint: '/api/v1/k8s/service/list',
|
||||||
|
path: '/k8s/service'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'configmap',
|
||||||
|
label: 'ConfigMap',
|
||||||
|
endpoint: '/api/v1/k8s/configmap/list',
|
||||||
|
path: '/k8s/configmap'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pod',
|
||||||
|
label: 'Pod',
|
||||||
|
endpoint: '/api/v1/k8s/pod/list',
|
||||||
|
path: '/k8s/pod'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pv',
|
||||||
|
label: 'PersistentVolume',
|
||||||
|
endpoint: '/api/v1/k8s/pv/list',
|
||||||
|
path: '/k8s/pv'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pvc',
|
||||||
|
label: 'PersistentVolumeClaim',
|
||||||
|
endpoint: '/api/v1/k8s/pvc/list',
|
||||||
|
path: '/k8s/pvc'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DRAWER_WIDTH = 240
|
||||||
27
frontend/src/utils/k8sHelpers.ts
Normal file
27
frontend/src/utils/k8sHelpers.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export const getAge = (timestamp: string): string => {
|
||||||
|
if (!timestamp) return '-'
|
||||||
|
const diff = Date.now() - new Date(timestamp).getTime()
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||||
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||||
|
if (days > 0) return `${days}d`
|
||||||
|
return `${hours}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDeleteEndpoint = (resourceKey: string): string => {
|
||||||
|
switch (resourceKey) {
|
||||||
|
case 'pod':
|
||||||
|
return '/api/v1/k8s/pod/delete'
|
||||||
|
case 'deployment':
|
||||||
|
return '/api/v1/k8s/deployment/delete'
|
||||||
|
case 'statefulset':
|
||||||
|
return '/api/v1/k8s/statefulset/delete'
|
||||||
|
case 'service':
|
||||||
|
return '/api/v1/k8s/service/delete'
|
||||||
|
case 'configmap':
|
||||||
|
return '/api/v1/k8s/configmap/delete'
|
||||||
|
case 'namespace':
|
||||||
|
return '/api/v1/k8s/namespace/delete'
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown resource type: ${resourceKey}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) (
|
|||||||
k8sAPI.Get("/statefulset/list", k8s.K8sStatefulSetList(ctx, db, store))
|
k8sAPI.Get("/statefulset/list", k8s.K8sStatefulSetList(ctx, db, store))
|
||||||
k8sAPI.Delete("/statefulset/delete", k8s.K8sStatefulSetDelete(ctx, db, store))
|
k8sAPI.Delete("/statefulset/delete", k8s.K8sStatefulSetDelete(ctx, db, store))
|
||||||
k8sAPI.Get("/configmap/list", k8s.K8sConfigMapList(ctx, db, store))
|
k8sAPI.Get("/configmap/list", k8s.K8sConfigMapList(ctx, db, store))
|
||||||
|
k8sAPI.Post("/configmap/create", k8s.K8sConfigMapCreate(ctx, db, store))
|
||||||
k8sAPI.Delete("/configmap/delete", k8s.K8sConfigMapDelete(ctx, db, store))
|
k8sAPI.Delete("/configmap/delete", k8s.K8sConfigMapDelete(ctx, db, store))
|
||||||
k8sAPI.Get("/pod/list", k8s.K8sPodList(ctx, db, store))
|
k8sAPI.Get("/pod/list", k8s.K8sPodList(ctx, db, store))
|
||||||
k8sAPI.Get("/pod/logs", k8s.K8sPodLogs(ctx, db, store))
|
k8sAPI.Get("/pod/logs", k8s.K8sPodLogs(ctx, db, store))
|
||||||
|
|||||||
@@ -927,6 +927,47 @@ func K8sConfigMapDelete(ctx context.Context, db *gorm.DB, store store.Store) fib
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func K8sConfigMapCreate(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Data map[string]string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return resp.R400(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Namespace == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("name and namespace are required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := getK8sClient(db)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configMap := &corev1.ConfigMap{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: req.Name,
|
||||||
|
Namespace: req.Namespace,
|
||||||
|
},
|
||||||
|
Data: req.Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := clientset.CoreV1().ConfigMaps(req.Namespace).Create(c.Context(), configMap, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to create configmap: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]any{
|
||||||
|
"name": created.Name,
|
||||||
|
"namespace": created.Namespace,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func K8sNamespaceDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func K8sNamespaceDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user