重构 K8s 资源管理页面

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

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

View File

@@ -1,11 +1,19 @@
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack, Menu, MenuItem, Avatar } from '@mui/material' import { Container, Typography, Box, AppBar, Toolbar, Button, Stack, Menu, MenuItem, Avatar } from '@mui/material'
import { Routes, Route, Link, Navigate } from 'react-router-dom' import { Routes, Route, Link, Navigate } from 'react-router-dom'
import { useState } from 'react' import { useState } from 'react'
import { AccountCircle, Logout } from '@mui/icons-material' import { Logout } from '@mui/icons-material'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { useAuthStore } from './stores/authStore' import { useAuthStore } from './stores/authStore'
import RegistryImageList from './pages/RegistryImageList' import RegistryImageList from './pages/RegistryImageList'
import K8sResourceList from './pages/K8sResourceList' import K8sLayout from './pages/k8s/K8sLayout'
import NamespacePage from './pages/k8s/NamespacePage'
import DeploymentPage from './pages/k8s/DeploymentPage'
import StatefulSetPage from './pages/k8s/StatefulSetPage'
import ServicePage from './pages/k8s/ServicePage'
import ConfigMapPage from './pages/k8s/ConfigMapPage'
import PodPage from './pages/k8s/PodPage'
import PVPage from './pages/k8s/PVPage'
import PVCPage from './pages/k8s/PVCPage'
import UserManagement from './pages/UserManagement' import UserManagement from './pages/UserManagement'
import Login from './pages/Login' import Login from './pages/Login'
@@ -45,7 +53,7 @@ function App() {
</Typography> </Typography>
<Button color="inherit" component={Link} to="/"></Button> <Button color="inherit" component={Link} to="/"></Button>
<Button color="inherit" component={Link} to="/registry/image"></Button> <Button color="inherit" component={Link} to="/registry/image"></Button>
<Button color="inherit" component={Link} to="/k8s/resources"></Button> <Button color="inherit" component={Link} to="/k8s/configmap"></Button>
<Button color="inherit" component={Link} to="/users"></Button> <Button color="inherit" component={Link} to="/users"></Button>
<Box <Box
sx={{ sx={{
@@ -92,7 +100,17 @@ function App() {
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}> <Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Routes> <Routes>
<Route path="/registry/image" element={<RegistryImageList />} /> <Route path="/registry/image" element={<RegistryImageList />} />
<Route path="/k8s/resources" element={<K8sResourceList />} /> <Route path="/k8s" element={<K8sLayout />}>
<Route path="namespace" element={<NamespacePage />} />
<Route path="deployment" element={<DeploymentPage />} />
<Route path="statefulset" element={<StatefulSetPage />} />
<Route path="service" element={<ServicePage />} />
<Route path="configmap" element={<ConfigMapPage />} />
<Route path="pod" element={<PodPage />} />
<Route path="pv" element={<PVPage />} />
<Route path="pvc" element={<PVCPage />} />
<Route path="*" element={<Navigate to="/k8s/namespace" replace />} />
</Route>
<Route path="/users" element={<UserManagement />} /> <Route path="/users" element={<UserManagement />} />
<Route path="/" element={ <Route path="/" element={
<Box> <Box>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
}
}

View 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,
}
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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

View 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}`)
}
}

View File

@@ -83,6 +83,7 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) (
k8sAPI.Get("/statefulset/list", k8s.K8sStatefulSetList(ctx, db, store)) k8sAPI.Get("/statefulset/list", k8s.K8sStatefulSetList(ctx, db, store))
k8sAPI.Delete("/statefulset/delete", k8s.K8sStatefulSetDelete(ctx, db, store)) k8sAPI.Delete("/statefulset/delete", k8s.K8sStatefulSetDelete(ctx, db, store))
k8sAPI.Get("/configmap/list", k8s.K8sConfigMapList(ctx, db, store)) k8sAPI.Get("/configmap/list", k8s.K8sConfigMapList(ctx, db, store))
k8sAPI.Post("/configmap/create", k8s.K8sConfigMapCreate(ctx, db, store))
k8sAPI.Delete("/configmap/delete", k8s.K8sConfigMapDelete(ctx, db, store)) k8sAPI.Delete("/configmap/delete", k8s.K8sConfigMapDelete(ctx, db, store))
k8sAPI.Get("/pod/list", k8s.K8sPodList(ctx, db, store)) k8sAPI.Get("/pod/list", k8s.K8sPodList(ctx, db, store))
k8sAPI.Get("/pod/logs", k8s.K8sPodLogs(ctx, db, store)) k8sAPI.Get("/pod/logs", k8s.K8sPodLogs(ctx, db, store))

View File

@@ -927,6 +927,47 @@ func K8sConfigMapDelete(ctx context.Context, db *gorm.DB, store store.Store) fib
} }
} }
func K8sConfigMapCreate(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
return func(c fiber.Ctx) error {
var req struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Data map[string]string `json:"data"`
}
if err := json.Unmarshal(c.Body(), &req); err != nil {
return resp.R400(c, "", nil, err)
}
if req.Name == "" || req.Namespace == "" {
return resp.R400(c, "", nil, fmt.Errorf("name and namespace are required"))
}
clientset, err := getK8sClient(db)
if err != nil {
return resp.R500(c, "", nil, err)
}
configMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: req.Name,
Namespace: req.Namespace,
},
Data: req.Data,
}
created, err := clientset.CoreV1().ConfigMaps(req.Namespace).Create(c.Context(), configMap, metav1.CreateOptions{})
if err != nil {
return resp.R500(c, "", nil, fmt.Errorf("failed to create configmap: %w", err))
}
return resp.R200(c, map[string]any{
"name": created.Name,
"namespace": created.Namespace,
})
}
}
func K8sNamespaceDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler { func K8sNamespaceDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
return func(c fiber.Ctx) error { return func(c fiber.Ctx) error {
var req struct { var req struct {