diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 269ca58..29ffde2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,19 @@ import { Container, Typography, Box, AppBar, Toolbar, Button, Stack, Menu, MenuItem, Avatar } from '@mui/material' import { Routes, Route, Link, Navigate } from 'react-router-dom' import { useState } from 'react' -import { AccountCircle, Logout } from '@mui/icons-material' +import { Logout } from '@mui/icons-material' import { useAppStore } from './stores/appStore' import { useAuthStore } from './stores/authStore' import RegistryImageList from './pages/RegistryImageList' -import K8sResourceList from './pages/K8sResourceList' +import K8sLayout from './pages/k8s/K8sLayout' +import NamespacePage from './pages/k8s/NamespacePage' +import DeploymentPage from './pages/k8s/DeploymentPage' +import StatefulSetPage from './pages/k8s/StatefulSetPage' +import ServicePage from './pages/k8s/ServicePage' +import ConfigMapPage from './pages/k8s/ConfigMapPage' +import PodPage from './pages/k8s/PodPage' +import PVPage from './pages/k8s/PVPage' +import PVCPage from './pages/k8s/PVCPage' import UserManagement from './pages/UserManagement' import Login from './pages/Login' @@ -45,7 +53,7 @@ function App() { - + } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> @@ -126,4 +144,4 @@ function App() { ) } -export default App +export default App \ No newline at end of file diff --git a/frontend/src/components/CreateConfigMapDialog.tsx b/frontend/src/components/CreateConfigMapDialog.tsx new file mode 100644 index 0000000..c038070 --- /dev/null +++ b/frontend/src/components/CreateConfigMapDialog.tsx @@ -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) => void + namespaces: string[] +} + +export default function CreateConfigMapDialog({ + open, + onClose, + onSubmit, + namespaces, +}: CreateConfigMapDialogProps) { + const [name, setName] = useState('') + const [namespace, setNamespace] = useState('') + const [configMapData, setConfigMapData] = useState([{ 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) => { + 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 = {} + configMapData.forEach((d) => { + data[d.key] = d.value + }) + + onSubmit(name, namespace, data) + handleClose() + } + + const handleClose = () => { + setName('') + setNamespace('') + setConfigMapData([{ key: '', value: '' }]) + setError('') + onClose() + } + + return ( + + 创建 ConfigMap + + + {error && {error}} + + setNamespace(e.target.value)} + fullWidth + required + > + {namespaces.map((ns) => ( + + {ns} + + ))} + + + setName(e.target.value)} + fullWidth + required + /> + + + + Data + + + + {configMapData.map((data, index) => ( + + + handleDataChange(index, 'key', e.target.value)} + size="small" + sx={{ flex: 1 }} + required + /> + (fileInputRefs.current[index] = el)} + style={{ display: 'none' }} + onChange={(e) => handleFileUpload(index, e)} + /> + fileInputRefs.current[index]?.click()} + title="从文件上传" + > + + + handleRemoveData(index)} + disabled={configMapData.length === 1} + > + + + + handleDataChange(index, 'value', e.target.value)} + multiline + rows={4} + fullWidth + size="small" + /> + + ))} + + + + + + + + + ) +} diff --git a/frontend/src/components/k8s/CreateNamespaceDialog.tsx b/frontend/src/components/k8s/CreateNamespaceDialog.tsx new file mode 100644 index 0000000..ffc65f4 --- /dev/null +++ b/frontend/src/components/k8s/CreateNamespaceDialog.tsx @@ -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 ( + + + + 创建 Namespace + + + + + + + + {error && {error}} + setName(e.target.value)} + fullWidth + required + placeholder="例如: my-namespace" + helperText="只能包含小写字母、数字和连字符" + /> + + + + + + + + ) +} diff --git a/frontend/src/components/k8s/CreateYamlDialog.tsx b/frontend/src/components/k8s/CreateYamlDialog.tsx new file mode 100644 index 0000000..07c2fdd --- /dev/null +++ b/frontend/src/components/k8s/CreateYamlDialog.tsx @@ -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(null) + + const handleFileUpload = (event: React.ChangeEvent) => { + 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 ( + + + + 创建资源 + + + + + + + + + + + + onYamlChange(e.target.value)} + placeholder="粘贴或编辑 YAML 内容..." + fullWidth + variant="outlined" + /> + + + + + + + + ) +} diff --git a/frontend/src/components/k8s/DeleteConfirmDialog.tsx b/frontend/src/components/k8s/DeleteConfirmDialog.tsx new file mode 100644 index 0000000..092bb84 --- /dev/null +++ b/frontend/src/components/k8s/DeleteConfirmDialog.tsx @@ -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 ( + + 确认删除 + + + 确定要删除 {resourceType} {resourceName} + {namespace && resourceType !== 'Namespace' ? ` (namespace: ${namespace})` : ''} 吗? + + + + + + + + ) +} diff --git a/frontend/src/components/k8s/EditResourceDialog.tsx b/frontend/src/components/k8s/EditResourceDialog.tsx new file mode 100644 index 0000000..2b685db --- /dev/null +++ b/frontend/src/components/k8s/EditResourceDialog.tsx @@ -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 ( + + + + + 编辑 {resourceType}: {resourceName} ({namespace}) + + + + + + + + onYamlChange(e.target.value)} + placeholder="YAML 内容..." + fullWidth + sx={{ mt: 1 }} + /> + + + + + + + ) +} diff --git a/frontend/src/components/k8s/KubeconfigSettingsDrawer.tsx b/frontend/src/components/k8s/KubeconfigSettingsDrawer.tsx new file mode 100644 index 0000000..b779fd8 --- /dev/null +++ b/frontend/src/components/k8s/KubeconfigSettingsDrawer.tsx @@ -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 ( + + + + 集群配置 + + + + + + + onKubeconfigChange(e.target.value)} + placeholder="粘贴 kubeconfig 内容..." + fullWidth + /> + + + + + ) +} diff --git a/frontend/src/components/k8s/PodLogsDialog.tsx b/frontend/src/components/k8s/PodLogsDialog.tsx new file mode 100644 index 0000000..9a1c4ff --- /dev/null +++ b/frontend/src/components/k8s/PodLogsDialog.tsx @@ -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(null) + + useEffect(() => { + logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [logs]) + + return ( + + + + + Pod 日志: {podName} ({namespace}) + + + + + + + + + {logs.length === 0 ? ( + 等待日志... + ) : ( + logs.map((log, index) => ( + + {log} + + )) + )} +
+ + + + + +
+ ) +} diff --git a/frontend/src/hooks/useKubeconfig.ts b/frontend/src/hooks/useKubeconfig.ts new file mode 100644 index 0000000..426422f --- /dev/null +++ b/frontend/src/hooks/useKubeconfig.ts @@ -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, + } +} diff --git a/frontend/src/hooks/useNamespaces.ts b/frontend/src/hooks/useNamespaces.ts new file mode 100644 index 0000000..833a5b7 --- /dev/null +++ b/frontend/src/hooks/useNamespaces.ts @@ -0,0 +1,39 @@ +import { useState, useEffect } from 'react' + +export function useNamespaces() { + const [namespaces, setNamespaces] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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, + } +} diff --git a/frontend/src/pages/K8sResourceList.tsx b/frontend/src/pages/K8sResourceList.tsx deleted file mode 100644 index 478333a..0000000 --- a/frontend/src/pages/K8sResourceList.tsx +++ /dev/null @@ -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([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [settingsOpen, setSettingsOpen] = useState(false) - const [kubeconfig, setKubeconfig] = useState('') - const [kubeconfigError, setKubeconfigError] = useState(false) - const [namespace, setNamespace] = useState('') - const [namespaces, setNamespaces] = useState([]) - 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(null) - const [logsDialogOpen, setLogsDialogOpen] = useState(false) - const [logs, setLogs] = useState([]) - const [selectedPod, setSelectedPod] = useState<{ name: string; namespace: string } | null>(null) - const eventSourceRef = useRef(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(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) => { - 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 ( - - {metadata.name || '-'} - {status.phase || '-'} - {getAge(metadata.creationTimestamp)} - - - openDeleteDialog(metadata.name, metadata.namespace)} - > - - - - - - ) - case 'deployment': - case 'statefulset': - return ( - - {metadata.name || '-'} - {metadata.namespace || '-'} - {`${status.readyReplicas || 0}/${spec.replicas || 0}`} - {getAge(metadata.creationTimestamp)} - - - handleEditResource(metadata.name, metadata.namespace, selectedKind.label)} - > - - - - - openDeleteDialog(metadata.name, metadata.namespace)} - > - - - - - - ) - 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 ( - - {metadata.name || '-'} - {metadata.namespace || '-'} - {spec.type || '-'} - {spec.clusterIP || '-'} - {portsDisplay} - {nodePortsDisplay} - {getAge(metadata.creationTimestamp)} - - - handleEditResource(metadata.name, metadata.namespace, selectedKind.label)} - > - - - - - openDeleteDialog(metadata.name, metadata.namespace)} - > - - - - - - ) - case 'configmap': - return ( - - {metadata.name || '-'} - {metadata.namespace || '-'} - {Object.keys(resource.data || {}).length} - {getAge(metadata.creationTimestamp)} - - - handleEditResource(metadata.name, metadata.namespace, selectedKind.label)} - > - - - - - openDeleteDialog(metadata.name, metadata.namespace)} - > - - - - - - ) - case 'pod': - return ( - - {metadata.name || '-'} - {metadata.namespace || '-'} - {status.phase || '-'} - {status.podIP || '-'} - {spec.nodeName || '-'} - {getAge(metadata.creationTimestamp)} - - handleViewLogs(metadata.name, metadata.namespace)} - title="查看日志" - > - - - openDeleteDialog(metadata.name, metadata.namespace)} - title="删除" - > - - - - - ) - case 'pv': - return ( - - {metadata.name || '-'} - {spec.capacity?.storage || '-'} - {spec.accessModes?.join(', ') || '-'} - {status.phase || '-'} - {spec.claimRef?.name ? `${spec.claimRef.namespace}/${spec.claimRef.name}` : '-'} - {getAge(metadata.creationTimestamp)} - - ) - case 'pvc': - return ( - - {metadata.name || '-'} - {metadata.namespace || '-'} - {status.phase || '-'} - {spec.volumeName || '-'} - {status.capacity?.storage || '-'} - {spec.accessModes?.join(', ') || '-'} - {getAge(metadata.creationTimestamp)} - - ) - default: - return ( - - {metadata.name || '-'} - - ) - } - } - - return ( - - - - {KINDS.map((kind) => ( - - setSelectedKind(kind)} - > - - - - ))} - - - - - - {selectedKind.label} - - setCreateDialogOpen(true)} sx={{ mr: 1 }}> - - - setSettingsOpen(true)}> - - - - - - {selectedKind.key !== 'namespace' && selectedKind.key !== 'pv' && ( - - setNamespace(e.target.value)} - size="small" - sx={{ width: 200 }} - SelectProps={{ - displayEmpty: true, - }} - InputLabelProps={{ shrink: true }} - > - - 所有命名空间 - - {namespaces.map((ns) => ( - - {ns} - - ))} - - setNameFilter(e.target.value)} - size="small" - sx={{ width: 200 }} - /> - - )} - - {kubeconfigError && ( - - 请先在右侧设置中配置 Kubeconfig - - )} - - {error && {error}} - - {loading && ( - - - - )} - - {!loading && !error && ( - - - - - {getResourceColumns().map((col) => ( - {col} - ))} - - - - {resources.length === 0 && ( - - - 暂无数据 - - - )} - {resources.map((resource) => renderResourceRow(resource))} - -
-
- )} -
- - setSettingsOpen(false)} - sx={{ '& .MuiDrawer-paper': { width: 500 } }} - > - - - 集群配置 - setSettingsOpen(false)}> - - - - - - setKubeconfig(e.target.value)} - placeholder="粘贴 kubeconfig 内容..." - fullWidth - /> - - - - - - setEditDialogOpen(false)} - maxWidth="md" - fullWidth - > - - - - 编辑 {editResource?.kind}: {editResource?.name} ({editResource?.namespace}) - - setEditDialogOpen(false)}> - - - - - - setEditYaml(e.target.value)} - placeholder="YAML 内容..." - fullWidth - variant="outlined" - sx={{ fontFamily: 'monospace', fontSize: '0.875rem' }} - /> - - - - - - - - setCreateDialogOpen(false)} - maxWidth="md" - fullWidth - > - - - 创建资源 - setCreateDialogOpen(false)}> - - - - - - - - - - - setYamlContent(e.target.value)} - placeholder="粘贴或编辑 YAML 内容..." - fullWidth - variant="outlined" - /> - - - - - - - - - - - - - Pod 日志: {selectedPod?.name} ({selectedPod?.namespace}) - - - - - - - - - {logs.length === 0 && 等待日志...} - {logs.map((log, index) => ( - - {log} - - ))} -
- - -
- - setDeleteDialogOpen(false)}> - 确认删除 - - - 确定要删除 {selectedKind.label} {deleteTarget?.name} - {deleteTarget?.namespace && selectedKind.key !== 'namespace' ? ` (namespace: ${deleteTarget?.namespace})` : ''} 吗? - - - - - - - - - setSnackbar({ ...snackbar, open: false })} - anchorOrigin={{ vertical: 'top', horizontal: 'center' }} - > - setSnackbar({ ...snackbar, open: false })}> - {snackbar.message} - - -
- ) -} diff --git a/frontend/src/pages/k8s/ConfigMapPage.tsx b/frontend/src/pages/k8s/ConfigMapPage.tsx new file mode 100644 index 0000000..8d4ca79 --- /dev/null +++ b/frontend/src/pages/k8s/ConfigMapPage.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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) => { + 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 ( + + + ConfigMap + setCreateDialogOpen(true)}> + + + + + + setNamespace(e.target.value)} + size="small" + sx={{ width: 200 }} + SelectProps={{ + displayEmpty: true, + }} + InputLabelProps={{ shrink: true }} + > + + 所有命名空间 + + {namespaces.map((ns) => ( + + {ns} + + ))} + + setNameFilter(e.target.value)} + size="small" + sx={{ width: 200 }} + /> + + + {error && {error}} + + {loading && ( + + + + )} + + {!loading && !error && ( + + + + + Name + Namespace + Data Keys + Age + Actions + + + + {resources.length === 0 && ( + + + 暂无数据 + + + )} + {resources.map((resource) => { + const metadata = resource.metadata || {} + return ( + + {metadata.name || '-'} + {metadata.namespace || '-'} + {Object.keys(resource.data || {}).length} + {getAge(metadata.creationTimestamp)} + + + handleEditResource(metadata.name, metadata.namespace)} + > + + + + + { + setDeleteTarget({ name: metadata.name, namespace: metadata.namespace }) + setDeleteDialogOpen(true) + }} + > + + + + + + ) + })} + +
+
+ )} + + setCreateDialogOpen(false)} + onSubmit={handleCreateConfigMap} + namespaces={namespaces} + /> + + setDeleteDialogOpen(false)} + onConfirm={handleDeleteResource} + resourceType="ConfigMap" + resourceName={deleteTarget?.name || ''} + namespace={deleteTarget?.namespace} + deleting={deleting} + /> + + setEditDialogOpen(false)} + onSave={handleSaveEdit} + resourceType="ConfigMap" + resourceName={editResource?.name || ''} + namespace={editResource?.namespace || ''} + yaml={editYaml} + onYamlChange={setEditYaml} + saving={editing} + /> + + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })}> + {snackbar.message} + + +
+ ) +} diff --git a/frontend/src/pages/k8s/DeploymentPage.tsx b/frontend/src/pages/k8s/DeploymentPage.tsx new file mode 100644 index 0000000..e9ca8fd --- /dev/null +++ b/frontend/src/pages/k8s/DeploymentPage.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( + + + Deployment + setCreateDialogOpen(true)}> + + + + + + setNamespace(e.target.value)} + size="small" + sx={{ width: 200 }} + SelectProps={{ + displayEmpty: true, + }} + InputLabelProps={{ shrink: true }} + > + + 所有命名空间 + + {namespaces.map((ns) => ( + + {ns} + + ))} + + setNameFilter(e.target.value)} + size="small" + sx={{ width: 200 }} + /> + + + {error && {error}} + + {loading && ( + + + + )} + + {!loading && !error && ( + + + + + Name + Namespace + Replicas + Age + Actions + + + + {resources.length === 0 && ( + + + 暂无数据 + + + )} + {resources.map((resource) => { + const metadata = resource.metadata || {} + const spec = resource.spec || {} + const status = resource.status || {} + return ( + + {metadata.name || '-'} + {metadata.namespace || '-'} + {`${status.readyReplicas || 0}/${spec.replicas || 0}`} + {getAge(metadata.creationTimestamp)} + + + handleEditResource(metadata.name, metadata.namespace)} + > + + + + + { + setDeleteTarget({ name: metadata.name, namespace: metadata.namespace }) + setDeleteDialogOpen(true) + }} + > + + + + + + ) + })} + +
+
+ )} + + setCreateDialogOpen(false)} + onApply={handleApplyYaml} + yamlContent={yamlContent} + onYamlChange={setYamlContent} + loading={applyLoading} + /> + + setDeleteDialogOpen(false)} + onConfirm={handleDeleteResource} + resourceType="Deployment" + resourceName={deleteTarget?.name || ''} + namespace={deleteTarget?.namespace} + deleting={deleting} + /> + + setEditDialogOpen(false)} + onSave={handleSaveEdit} + resourceType="Deployment" + resourceName={editResource?.name || ''} + namespace={editResource?.namespace || ''} + yaml={editYaml} + onYamlChange={setEditYaml} + saving={editing} + /> + + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })}> + {snackbar.message} + + +
+ ) +} diff --git a/frontend/src/pages/k8s/K8sLayout.tsx b/frontend/src/pages/k8s/K8sLayout.tsx new file mode 100644 index 0000000..2a06d45 --- /dev/null +++ b/frontend/src/pages/k8s/K8sLayout.tsx @@ -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 ( + + + + setSettingsOpen(true)}> + + + + + {RESOURCE_KINDS.map((kind) => ( + + navigate(kind.path)} + > + + + + ))} + + + + + + + + setSettingsOpen(false)} + kubeconfig={kubeconfig} + onKubeconfigChange={setKubeconfig} + onSave={handleSave} + /> + + ) +} diff --git a/frontend/src/pages/k8s/NamespacePage.tsx b/frontend/src/pages/k8s/NamespacePage.tsx new file mode 100644 index 0000000..1749f89 --- /dev/null +++ b/frontend/src/pages/k8s/NamespacePage.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( + + + Namespace + setCreateDialogOpen(true)}> + + + + + + setNameFilter(e.target.value)} + size="small" + sx={{ width: 200 }} + /> + + + {error && {error}} + + {loading && ( + + + + )} + + {!loading && !error && ( + + + + + Name + Status + Age + Actions + + + + {resources.length === 0 && ( + + + 暂无数据 + + + )} + {resources.map((resource) => { + const metadata = resource.metadata || {} + const status = resource.status || {} + return ( + + {metadata.name || '-'} + {status.phase || '-'} + {getAge(metadata.creationTimestamp)} + + + { + setDeleteTarget({ name: metadata.name }) + setDeleteDialogOpen(true) + }} + > + + + + + + ) + })} + +
+
+ )} + + setCreateDialogOpen(false)} + onSubmit={handleCreateNamespace} + /> + + setDeleteDialogOpen(false)} + onConfirm={handleDeleteResource} + resourceType="Namespace" + resourceName={deleteTarget?.name || ''} + deleting={deleting} + /> + + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })}> + {snackbar.message} + + +
+ ) +} diff --git a/frontend/src/pages/k8s/PVCPage.tsx b/frontend/src/pages/k8s/PVCPage.tsx new file mode 100644 index 0000000..5564edf --- /dev/null +++ b/frontend/src/pages/k8s/PVCPage.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( + + + PersistentVolumeClaim + setCreateDialogOpen(true)}> + + + + + + setNamespace(e.target.value)} + size="small" + sx={{ width: 200 }} + SelectProps={{ + displayEmpty: true, + }} + InputLabelProps={{ shrink: true }} + > + + 所有命名空间 + + {namespaces.map((ns) => ( + + {ns} + + ))} + + setNameFilter(e.target.value)} + size="small" + sx={{ width: 200 }} + /> + + + {error && {error}} + + {loading && ( + + + + )} + + {!loading && !error && ( + + + + + Name + Namespace + Status + Volume + Capacity + Access Modes + Age + Actions + + + + {resources.length === 0 && ( + + + 暂无数据 + + + )} + {resources.map((resource) => { + const metadata = resource.metadata || {} + const spec = resource.spec || {} + const status = resource.status || {} + return ( + + {metadata.name || '-'} + {metadata.namespace || '-'} + {status.phase || '-'} + {spec.volumeName || '-'} + {status.capacity?.storage || '-'} + {spec.accessModes?.join(', ') || '-'} + {getAge(metadata.creationTimestamp)} + + + { + setDeleteTarget({ name: metadata.name, namespace: metadata.namespace }) + setDeleteDialogOpen(true) + }} + > + + + + + + ) + })} + +
+
+ )} + + setCreateDialogOpen(false)} + onApply={handleApplyYaml} + yamlContent={yamlContent} + onYamlChange={setYamlContent} + loading={applyLoading} + /> + + setDeleteDialogOpen(false)} + onConfirm={handleDeleteResource} + resourceType="PersistentVolumeClaim" + resourceName={deleteTarget?.name || ''} + namespace={deleteTarget?.namespace} + deleting={deleting} + /> + + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })}> + {snackbar.message} + + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/k8s/PVPage.tsx b/frontend/src/pages/k8s/PVPage.tsx new file mode 100644 index 0000000..8396ec3 --- /dev/null +++ b/frontend/src/pages/k8s/PVPage.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( + + + PersistentVolume + setCreateDialogOpen(true)}> + + + + + + setNameFilter(e.target.value)} + size="small" + sx={{ width: 200 }} + /> + + + {error && {error}} + + {loading && ( + + + + )} + + {!loading && !error && ( + + + + + Name + Capacity + Access Modes + Status + Claim + Age + Actions + + + + {resources.length === 0 && ( + + + 暂无数据 + + + )} + {resources.map((resource) => { + const metadata = resource.metadata || {} + const spec = resource.spec || {} + const status = resource.status || {} + return ( + + {metadata.name || '-'} + {spec.capacity?.storage || '-'} + {spec.accessModes?.join(', ') || '-'} + {status.phase || '-'} + {spec.claimRef?.name ? `${spec.claimRef.namespace}/${spec.claimRef.name}` : '-'} + {getAge(metadata.creationTimestamp)} + + + { + setDeleteTarget({ name: metadata.name }) + setDeleteDialogOpen(true) + }} + > + + + + + + ) + })} + +
+
+ )} + + setCreateDialogOpen(false)} + onApply={handleApplyYaml} + yamlContent={yamlContent} + onYamlChange={setYamlContent} + loading={applyLoading} + /> + + setDeleteDialogOpen(false)} + onConfirm={handleDeleteResource} + resourceType="PersistentVolume" + resourceName={deleteTarget?.name || ''} + deleting={deleting} + /> + + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })}> + {snackbar.message} + + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/k8s/PodPage.tsx b/frontend/src/pages/k8s/PodPage.tsx new file mode 100644 index 0000000..5851d9c --- /dev/null +++ b/frontend/src/pages/k8s/PodPage.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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([]) + 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 ( + + + Pod + {/* No create button for Pods */} + + + + setNamespace(e.target.value)} + size="small" + sx={{ width: 200 }} + SelectProps={{ + displayEmpty: true, + }} + InputLabelProps={{ shrink: true }} + > + + 所有命名空间 + + {namespaces.map((ns) => ( + + {ns} + + ))} + + setNameFilter(e.target.value)} + size="small" + sx={{ width: 200 }} + /> + + + {error && {error}} + + {loading && ( + + + + )} + + {!loading && !error && ( + + + + + Name + Namespace + Status + IP + Node + Age + Actions + + + + {resources.length === 0 && ( + + + 暂无数据 + + + )} + {resources.map((resource) => { + const metadata = resource.metadata || {} + const spec = resource.spec || {} + const status = resource.status || {} + return ( + + {metadata.name || '-'} + {metadata.namespace || '-'} + {status.phase || '-'} + {status.podIP || '-'} + {spec.nodeName || '-'} + {getAge(metadata.creationTimestamp)} + + handleViewLogs(metadata.name, metadata.namespace)} + title="查看日志" + > + + + { + setDeleteTarget({ name: metadata.name, namespace: metadata.namespace }) + setDeleteDialogOpen(true) + }} + title="删除" + > + + + + + ) + })} + +
+
+ )} + + setDeleteDialogOpen(false)} + onConfirm={handleDeleteResource} + resourceType="Pod" + resourceName={deleteTarget?.name || ''} + namespace={deleteTarget?.namespace} + deleting={deleting} + /> + + setLogsDialogOpen(false)} + podName={selectedPod?.name || ''} + namespace={selectedPod?.namespace || ''} + logs={logs} + /> + + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })}> + {snackbar.message} + + +
+ ) +} diff --git a/frontend/src/pages/k8s/ServicePage.tsx b/frontend/src/pages/k8s/ServicePage.tsx new file mode 100644 index 0000000..d74aae5 --- /dev/null +++ b/frontend/src/pages/k8s/ServicePage.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( + + + Service + + + + + + + setNamespace(e.target.value)} + size="small" + sx={{ width: 200 }} + SelectProps={{ + displayEmpty: true, + }} + InputLabelProps={{ shrink: true }} + > + + 所有命名空间 + + {namespaces.map((ns) => ( + + {ns} + + ))} + + setNameFilter(e.target.value)} + size="small" + sx={{ width: 200 }} + /> + + + {error && {error}} + + {loading && ( + + + + )} + + {!loading && !error && ( + + + + + Name + Namespace + Type + Cluster IP + Ports + NodePort + Age + Actions + + + + {resources.length === 0 && ( + + + 暂无数据 + + + )} + {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 ( + + {metadata.name || '-'} + {metadata.namespace || '-'} + {spec.type || '-'} + {spec.clusterIP || '-'} + {portsDisplay} + {nodePortsDisplay} + {getAge(metadata.creationTimestamp)} + + + handleEditResource(metadata.name, metadata.namespace)} + > + + + + + { + setDeleteTarget({ name: metadata.name, namespace: metadata.namespace }) + setDeleteDialogOpen(true) + }} + > + + + + + + ) + })} + +
+
+ )} + + {/* Feature Coming Soon Dialog */} + setFeatureDialogOpen(false)}> + 功能开发中 + + + Service 创建功能正在开发中,请通过 YAML 文件方式创建。 + + + + + + + + setCreateDialogOpen(false)} + onApply={handleApplyYaml} + yamlContent={yamlContent} + onYamlChange={setYamlContent} + loading={applyLoading} + /> + + setDeleteDialogOpen(false)} + onConfirm={handleDeleteResource} + resourceType="Service" + resourceName={deleteTarget?.name || ''} + namespace={deleteTarget?.namespace} + deleting={deleting} + /> + + setEditDialogOpen(false)} + onSave={handleSaveEdit} + resourceType="Service" + resourceName={editResource?.name || ''} + namespace={editResource?.namespace || ''} + yaml={editYaml} + onYamlChange={setEditYaml} + saving={editing} + /> + + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })}> + {snackbar.message} + + +
+ ) +} diff --git a/frontend/src/pages/k8s/StatefulSetPage.tsx b/frontend/src/pages/k8s/StatefulSetPage.tsx new file mode 100644 index 0000000..cf6c274 --- /dev/null +++ b/frontend/src/pages/k8s/StatefulSetPage.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( + + + StatefulSet + setCreateDialogOpen(true)}> + + + + + + setNamespace(e.target.value)} + size="small" + sx={{ width: 200 }} + SelectProps={{ + displayEmpty: true, + }} + InputLabelProps={{ shrink: true }} + > + + 所有命名空间 + + {namespaces.map((ns) => ( + + {ns} + + ))} + + setNameFilter(e.target.value)} + size="small" + sx={{ width: 200 }} + /> + + + {error && {error}} + + {loading && ( + + + + )} + + {!loading && !error && ( + + + + + Name + Namespace + Replicas + Age + Actions + + + + {resources.length === 0 && ( + + + 暂无数据 + + + )} + {resources.map((resource) => { + const metadata = resource.metadata || {} + const spec = resource.spec || {} + const status = resource.status || {} + return ( + + {metadata.name || '-'} + {metadata.namespace || '-'} + {`${status.readyReplicas || 0}/${spec.replicas || 0}`} + {getAge(metadata.creationTimestamp)} + + + handleEditResource(metadata.name, metadata.namespace)} + > + + + + + { + setDeleteTarget({ name: metadata.name, namespace: metadata.namespace }) + setDeleteDialogOpen(true) + }} + > + + + + + + ) + })} + +
+
+ )} + + setCreateDialogOpen(false)} + onApply={handleApplyYaml} + yamlContent={yamlContent} + onYamlChange={setYamlContent} + loading={applyLoading} + /> + + setDeleteDialogOpen(false)} + onConfirm={handleDeleteResource} + resourceType="StatefulSet" + resourceName={deleteTarget?.name || ''} + namespace={deleteTarget?.namespace} + deleting={deleting} + /> + + setEditDialogOpen(false)} + onSave={handleSaveEdit} + resourceType="StatefulSet" + resourceName={editResource?.name || ''} + namespace={editResource?.namespace || ''} + yaml={editYaml} + onYamlChange={setEditYaml} + saving={editing} + /> + + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })}> + {snackbar.message} + + +
+ ) +} diff --git a/frontend/src/utils/k8sConstants.ts b/frontend/src/utils/k8sConstants.ts new file mode 100644 index 0000000..4f52100 --- /dev/null +++ b/frontend/src/utils/k8sConstants.ts @@ -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 diff --git a/frontend/src/utils/k8sHelpers.ts b/frontend/src/utils/k8sHelpers.ts new file mode 100644 index 0000000..7c84a3c --- /dev/null +++ b/frontend/src/utils/k8sHelpers.ts @@ -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}`) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index e7f6797..255f19f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -83,6 +83,7 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) ( k8sAPI.Get("/statefulset/list", k8s.K8sStatefulSetList(ctx, db, store)) k8sAPI.Delete("/statefulset/delete", k8s.K8sStatefulSetDelete(ctx, db, store)) k8sAPI.Get("/configmap/list", k8s.K8sConfigMapList(ctx, db, store)) + k8sAPI.Post("/configmap/create", k8s.K8sConfigMapCreate(ctx, db, store)) k8sAPI.Delete("/configmap/delete", k8s.K8sConfigMapDelete(ctx, db, store)) k8sAPI.Get("/pod/list", k8s.K8sPodList(ctx, db, store)) k8sAPI.Get("/pod/logs", k8s.K8sPodLogs(ctx, db, store)) @@ -128,4 +129,4 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) ( } return fn, nil -} +} \ No newline at end of file diff --git a/internal/module/k8s/handler.resource.go b/internal/module/k8s/handler.resource.go index f28c52e..ff07fe5 100644 --- a/internal/module/k8s/handler.resource.go +++ b/internal/module/k8s/handler.resource.go @@ -927,6 +927,47 @@ func K8sConfigMapDelete(ctx context.Context, db *gorm.DB, store store.Store) fib } } +func K8sConfigMapCreate(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler { + return func(c fiber.Ctx) error { + var req struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Data map[string]string `json:"data"` + } + + if err := json.Unmarshal(c.Body(), &req); err != nil { + return resp.R400(c, "", nil, err) + } + + if req.Name == "" || req.Namespace == "" { + return resp.R400(c, "", nil, fmt.Errorf("name and namespace are required")) + } + + clientset, err := getK8sClient(db) + if err != nil { + return resp.R500(c, "", nil, err) + } + + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: req.Name, + Namespace: req.Namespace, + }, + Data: req.Data, + } + + created, err := clientset.CoreV1().ConfigMaps(req.Namespace).Create(c.Context(), configMap, metav1.CreateOptions{}) + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to create configmap: %w", err)) + } + + return resp.R200(c, map[string]any{ + "name": created.Name, + "namespace": created.Namespace, + }) + } +} + func K8sNamespaceDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler { return func(c fiber.Ctx) error { var req struct { @@ -955,4 +996,4 @@ func K8sNamespaceDelete(ctx context.Context, db *gorm.DB, store store.Store) fib "name": req.Name, }) } -} +} \ No newline at end of file