重构 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 { Routes, Route, Link, Navigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { AccountCircle, Logout } from '@mui/icons-material'
|
||||
import { Logout } from '@mui/icons-material'
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
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 Login from './pages/Login'
|
||||
|
||||
@@ -45,7 +53,7 @@ function App() {
|
||||
</Typography>
|
||||
<Button color="inherit" component={Link} to="/">首页</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>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -92,7 +100,17 @@ function App() {
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Routes>
|
||||
<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="/" element={
|
||||
<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.Delete("/statefulset/delete", k8s.K8sStatefulSetDelete(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.Get("/pod/list", k8s.K8sPodList(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 {
|
||||
return func(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
|
||||
Reference in New Issue
Block a user