feat: add k8s cluster resource management module

- Add K8s module with kubeconfig storage in cluster_config table
- Implement resource list APIs for 8 kinds: namespace, deployment, statefulset, service, configmap, pod, pv, pvc
- Add K8sResourceList page with sidebar navigation and resource tables
- Support YAML upload/input dialog for resource creation via dynamic client
- Add kubeconfig settings drawer
- Increase main content width from lg to xl for better table display
- Add navigation link for cluster resources in top bar

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
loveuer
2025-11-12 21:06:14 +08:00
parent dfb6fb7624
commit db28bc0425
11 changed files with 1069 additions and 10 deletions

View File

@@ -2,6 +2,7 @@ import { Container, Typography, Box, AppBar, Toolbar, Button, Stack } from '@mui
import { Routes, Route, Link } from 'react-router-dom'
import { useAppStore } from './stores/appStore'
import RegistryImageList from './pages/RegistryImageList'
import K8sResourceList from './pages/K8sResourceList'
function App() {
const { count, increment, decrement, reset } = useAppStore()
@@ -15,11 +16,13 @@ function App() {
</Typography>
<Button color="inherit" component={Link} to="/"></Button>
<Button color="inherit" component={Link} to="/registry/image"></Button>
<Button color="inherit" component={Link} to="/k8s/resources"></Button>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Routes>
<Route path="/registry/image" element={<RegistryImageList />} />
<Route path="/k8s/resources" element={<K8sResourceList />} />
<Route path="/" element={
<Box>
<Typography variant="h4" component="h1" gutterBottom>

View File

@@ -0,0 +1,484 @@
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,
} 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'
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 [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)
useEffect(() => {
fetchKubeconfig()
}, [])
useEffect(() => {
if (kubeconfig) {
fetchResources()
}
}, [selectedKind, namespace])
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 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)
}
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 getResourceColumns = () => {
switch (selectedKind.key) {
case 'namespace':
return ['Name', 'Status', 'Age']
case 'deployment':
case 'statefulset':
return ['Name', 'Namespace', 'Replicas', 'Age']
case 'service':
return ['Name', 'Namespace', 'Type', 'Cluster IP', 'External IP', 'Ports', 'Age']
case 'configmap':
return ['Name', 'Namespace', 'Data Keys', 'Age']
case 'pod':
return ['Name', 'Namespace', 'Status', 'IP', 'Node', 'Age']
case 'pv':
return ['Name', 'Capacity', 'Access Modes', 'Status', 'Claim', 'Age']
case 'pvc':
return ['Name', 'Namespace', 'Status', 'Volume', 'Capacity', 'Access Modes', 'Age']
default:
return ['Name']
}
}
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>
</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>
</TableRow>
)
case 'service':
return (
<TableRow key={metadata.uid}>
<TableCell>{metadata.name || '-'}</TableCell>
<TableCell>{metadata.namespace || '-'}</TableCell>
<TableCell>{spec.type || '-'}</TableCell>
<TableCell>{spec.clusterIP || '-'}</TableCell>
<TableCell>{spec.externalIPs?.join(', ') || status.loadBalancer?.ingress?.map((i: any) => i.ip || i.hostname).join(', ') || '-'}</TableCell>
<TableCell>{spec.ports?.map((p: any) => `${p.port}/${p.protocol}`).join(', ') || '-'}</TableCell>
<TableCell>{getAge(metadata.creationTimestamp)}</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>
</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>
</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 }}>
<TextField
placeholder="Namespace (空则查询所有)"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
size="small"
sx={{ width: 300 }}
/>
</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={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>
<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>
)
}