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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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) => (
-
- ))}
-
- 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
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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) => (
+
+ ))}
+
+ 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) => (
+
+ ))}
+
+ 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) => (
+
+ ))}
+
+ 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) => (
+
+ ))}
+
+ 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) => (
+
+ ))}
+
+ 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 */}
+
+
+ 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) => (
+
+ ))}
+
+ 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