add more feature
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { useTheme } from './hooks/useTheme'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Register from './pages/Register'
|
import Register from './pages/Register'
|
||||||
import DashboardLayout from './layout/DashboardLayout'
|
import DashboardLayout from './layout/DashboardLayout'
|
||||||
import AuthGate from './middleware/AuthGate'
|
import AuthGate from './middleware/AuthGate'
|
||||||
|
import Landing from './pages/Landing'
|
||||||
import CreateCluster from './pages/CreateCluster'
|
import CreateCluster from './pages/CreateCluster'
|
||||||
import ClusterDetail from './pages/ClusterDetail'
|
import ClusterDetail from './pages/ClusterDetail'
|
||||||
import Namespaces from './pages/Namespaces'
|
import Namespaces from './pages/Namespaces'
|
||||||
@@ -23,11 +25,14 @@ import StorageClasses from './pages/StorageClasses'
|
|||||||
import ServiceAccounts from './pages/ServiceAccounts'
|
import ServiceAccounts from './pages/ServiceAccounts'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
import Resources from './pages/Resources'
|
import Resources from './pages/Resources'
|
||||||
|
import HelmApps from './pages/HelmApps'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
// Initialize theme on app load
|
||||||
|
useTheme()
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<Route path="/" element={<Landing />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route element={<AuthGate />}>
|
<Route element={<AuthGate />}>
|
||||||
@@ -53,6 +58,7 @@ function App() {
|
|||||||
<Route path="serviceaccounts" element={<ServiceAccounts />} />
|
<Route path="serviceaccounts" element={<ServiceAccounts />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
<Route path="resources" element={<Resources />} />
|
<Route path="resources" element={<Resources />} />
|
||||||
|
<Route path="helm-apps" element={<HelmApps />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
|||||||
50
src/hooks/useTheme.ts
Normal file
50
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
// Cookie utility functions
|
||||||
|
const setCookie = (name: string, value: string, days: number = 365) => {
|
||||||
|
const expires = new Date()
|
||||||
|
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000))
|
||||||
|
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCookie = (name: string): string | null => {
|
||||||
|
const nameEQ = name + "="
|
||||||
|
const ca = document.cookie.split(';')
|
||||||
|
for (let i = 0; i < ca.length; i++) {
|
||||||
|
let c = ca[i]
|
||||||
|
while (c.charAt(0) === ' ') c = c.substring(1, c.length)
|
||||||
|
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme to document
|
||||||
|
const applyTheme = (theme: 'light' | 'dark') => {
|
||||||
|
document.documentElement.classList.remove('light', 'dark')
|
||||||
|
document.documentElement.classList.add(theme)
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Get theme from cookie on mount
|
||||||
|
const storedTheme = getCookie('theme') as 'light' | 'dark' | null
|
||||||
|
if (storedTheme) {
|
||||||
|
setTheme(storedTheme)
|
||||||
|
applyTheme(storedTheme)
|
||||||
|
} else {
|
||||||
|
// Default to light theme and apply it
|
||||||
|
applyTheme('light')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const changeTheme = (newTheme: 'light' | 'dark') => {
|
||||||
|
setTheme(newTheme)
|
||||||
|
setCookie('theme', newTheme)
|
||||||
|
applyTheme(newTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { theme, changeTheme }
|
||||||
|
}
|
||||||
@@ -4,10 +4,81 @@
|
|||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Light theme (default) */
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 text-gray-900;
|
@apply bg-gray-50 text-gray-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark theme */
|
||||||
|
.dark body {
|
||||||
|
@apply bg-gray-900 text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-white {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-gray-200 {
|
||||||
|
@apply border-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-gray-900 {
|
||||||
|
@apply text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-gray-700 {
|
||||||
|
@apply text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-gray-600 {
|
||||||
|
@apply text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-gray-500 {
|
||||||
|
@apply text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-gray-50 {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:bg-gray-50:hover {
|
||||||
|
@apply hover:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-gray-300 {
|
||||||
|
@apply border-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .focus\:ring-blue-500:focus {
|
||||||
|
@apply focus:ring-blue-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-blue-50 {
|
||||||
|
@apply bg-blue-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-blue-200 {
|
||||||
|
@apply border-blue-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-blue-800 {
|
||||||
|
@apply text-blue-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-blue-700 {
|
||||||
|
@apply text-blue-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-blue-300 {
|
||||||
|
@apply border-blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:bg-blue-100:hover {
|
||||||
|
@apply hover:bg-blue-800;
|
||||||
|
}
|
||||||
|
|
||||||
/* shadcn-like utilities */
|
/* shadcn-like utilities */
|
||||||
.container {
|
.container {
|
||||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box size={18} />
|
<Box size={18} />
|
||||||
Namespaces (4)
|
Namespaces
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
{/* <NavLink
|
||||||
to="nodes"
|
to="nodes"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium ${
|
`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium ${
|
||||||
@@ -77,7 +77,7 @@ export default function DashboardLayout() {
|
|||||||
>
|
>
|
||||||
<Cpu size={18} />
|
<Cpu size={18} />
|
||||||
Nodes (3)
|
Nodes (3)
|
||||||
</NavLink>
|
</NavLink> */}
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="pods"
|
to="pods"
|
||||||
@@ -88,7 +88,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Activity size={18} />
|
<Activity size={18} />
|
||||||
Pods (12)
|
Pods
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -100,7 +100,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Layers size={18} />
|
<Layers size={18} />
|
||||||
Deployments (8)
|
Deployments
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -112,7 +112,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Copy size={18} />
|
<Copy size={18} />
|
||||||
ReplicaSets (12)
|
ReplicaSets
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -124,7 +124,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Database size={18} />
|
<Database size={18} />
|
||||||
StatefulSets (3)
|
StatefulSets
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -136,7 +136,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Zap size={18} />
|
<Zap size={18} />
|
||||||
DaemonSets (2)
|
DaemonSets
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -148,7 +148,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Activity size={18} />
|
<Activity size={18} />
|
||||||
Jobs (5)
|
Jobs
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -160,7 +160,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Clock size={18} />
|
<Clock size={18} />
|
||||||
CronJobs (4)
|
CronJobs
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -172,7 +172,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Copy size={18} />
|
<Copy size={18} />
|
||||||
ReplicationControllers (1)
|
ReplicationControllers
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -184,7 +184,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Network size={18} />
|
<Network size={18} />
|
||||||
Services (6)
|
Services
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -196,7 +196,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsIcon size={18} />
|
<SettingsIcon size={18} />
|
||||||
ConfigMaps (15)
|
ConfigMaps
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -208,7 +208,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Shield size={18} />
|
<Shield size={18} />
|
||||||
Secrets (9)
|
Secrets
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -220,7 +220,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<HardDrive size={18} />
|
<HardDrive size={18} />
|
||||||
PersistentVolumes (5)
|
PersistentVolumes
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -232,7 +232,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Database size={18} />
|
<Database size={18} />
|
||||||
StorageClasses (3)
|
StorageClasses
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -244,7 +244,7 @@ export default function DashboardLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Users size={18} />
|
<Users size={18} />
|
||||||
ServiceAccounts (7)
|
ServiceAccounts
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,6 +260,17 @@ export default function DashboardLayout() {
|
|||||||
<Box size={18} />
|
<Box size={18} />
|
||||||
Resources
|
Resources
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="helm-apps"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium ${
|
||||||
|
isActive ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box size={18} />
|
||||||
|
Helm Apps
|
||||||
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="settings"
|
to="settings"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
@@ -276,6 +287,8 @@ export default function DashboardLayout() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
localStorage.removeItem('auth:user')
|
localStorage.removeItem('auth:user')
|
||||||
|
localStorage.removeItem('auth:token')
|
||||||
|
localStorage.removeItem('selectedCluster')
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
|
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
Box,
|
|
||||||
Cpu,
|
|
||||||
Database,
|
|
||||||
HardDrive,
|
|
||||||
Layers,
|
|
||||||
Network,
|
|
||||||
Settings,
|
|
||||||
Shield,
|
|
||||||
Users,
|
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -63,53 +53,133 @@ async function getCluster(id: string): Promise<Cluster | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const resourceTypes = [
|
// const resourceTypes = [...]; // unused placeholder removed
|
||||||
{ name: 'Namespaces', icon: Box, count: 4, color: 'bg-blue-500' },
|
|
||||||
{ name: 'Nodes', icon: Cpu, count: 3, color: 'bg-green-500' },
|
|
||||||
{ name: 'Pods', icon: Activity, count: 12, color: 'bg-purple-500' },
|
|
||||||
{ name: 'Deployments', icon: Layers, count: 8, color: 'bg-orange-500' },
|
|
||||||
{ name: 'Services', icon: Network, count: 6, color: 'bg-indigo-500' },
|
|
||||||
{ name: 'ConfigMaps', icon: Settings, count: 15, color: 'bg-yellow-500' },
|
|
||||||
{ name: 'Secrets', icon: Shield, count: 9, color: 'bg-red-500' },
|
|
||||||
{ name: 'PersistentVolumes', icon: HardDrive, count: 5, color: 'bg-teal-500' },
|
|
||||||
{ name: 'StorageClasses', icon: Database, count: 3, color: 'bg-pink-500' },
|
|
||||||
{ name: 'ServiceAccounts', icon: Users, count: 7, color: 'bg-gray-500' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Sample cluster statistics data
|
// Sample placeholders removed; panels now use live APIs
|
||||||
const clusterStats = {
|
|
||||||
resourceUsage: {
|
|
||||||
cpu: { used: 65, total: 100, unit: 'cores' },
|
|
||||||
memory: { used: 8.2, total: 16, unit: 'GB' },
|
|
||||||
storage: { used: 45, total: 100, unit: 'GB' },
|
|
||||||
network: { used: 2.1, total: 10, unit: 'Gbps' }
|
|
||||||
},
|
|
||||||
performance: {
|
|
||||||
podStartupTime: '2.3s',
|
|
||||||
apiLatency: '45ms',
|
|
||||||
etcdLatency: '12ms',
|
|
||||||
schedulerLatency: '8ms'
|
|
||||||
},
|
|
||||||
health: {
|
|
||||||
nodesHealthy: 3,
|
|
||||||
nodesTotal: 3,
|
|
||||||
podsRunning: 10,
|
|
||||||
podsTotal: 12,
|
|
||||||
alerts: 2,
|
|
||||||
warnings: 1
|
|
||||||
},
|
|
||||||
uptime: {
|
|
||||||
clusterUptime: '15d 8h 32m',
|
|
||||||
lastMaintenance: '3d ago',
|
|
||||||
nextMaintenance: '11d from now'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ClusterDetail() {
|
export default function ClusterDetail() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
type Usage = { Used: number, Total: number, Unit: string }
|
||||||
|
type ResourceUsage = { CPU: Usage, Memory: Usage, Storage: Usage, Network: Usage }
|
||||||
|
const toUsage = (obj: any): Usage | null => {
|
||||||
|
if (!obj) return null
|
||||||
|
const used = obj.Used ?? obj.used
|
||||||
|
const total = obj.Total ?? obj.total
|
||||||
|
const unit = obj.Unit ?? obj.unit
|
||||||
|
if (used == null || total == null || unit == null) return null
|
||||||
|
return { Used: Number(used), Total: Number(total), Unit: String(unit) }
|
||||||
|
}
|
||||||
|
const normalizeUsage = (data: any): ResourceUsage | null => {
|
||||||
|
if (!data) return null
|
||||||
|
const cpu = toUsage(data.CPU ?? data.cpu)
|
||||||
|
const memory = toUsage(data.Memory ?? data.memory)
|
||||||
|
const storage = toUsage(data.Storage ?? data.storage)
|
||||||
|
const network = toUsage(data.Network ?? data.network)
|
||||||
|
if (!cpu || !memory || !storage || !network) return null
|
||||||
|
return { CPU: cpu, Memory: memory, Storage: storage, Network: network }
|
||||||
|
}
|
||||||
const [cluster, setCluster] = useState<Cluster | null>(null);
|
const [cluster, setCluster] = useState<Cluster | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [usage, setUsage] = useState<ResourceUsage | null>(null)
|
||||||
|
const [loadingUsage, setLoadingUsage] = useState<boolean>(false)
|
||||||
|
type Health = { NodesHealthy: number, NodesTotal: number, PodsRunning: number, PodsTotal: number, Alerts: number, Warnings: number, Status: string }
|
||||||
|
const [healthState, setHealthState] = useState<Health | null>(null)
|
||||||
|
const [loadingHealth, setLoadingHealth] = useState<boolean>(false)
|
||||||
|
type Performance = { PodStartupTime: string, ApiLatency: string, EtcdLatency: string, SchedulerLatency: string }
|
||||||
|
const [performanceState, setPerformanceState] = useState<Performance | null>(null)
|
||||||
|
const [loadingPerformance, setLoadingPerformance] = useState<boolean>(false)
|
||||||
|
type Uptime = { ClusterUptime: string, LastMaintenance: string, NextMaintenance: string }
|
||||||
|
const [uptimeState, setUptimeState] = useState<Uptime | null>(null)
|
||||||
|
const [loadingUptime, setLoadingUptime] = useState<boolean>(false)
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const downloadKubeconfig = async (clusterName: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/connect?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const kubeconfig = await response.text()
|
||||||
|
const blob = new Blob([kubeconfig], { type: 'text/yaml' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `kubeconfig-${clusterName}.yaml`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
console.error('Failed to download kubeconfig')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading kubeconfig:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events panel state
|
||||||
|
type EventItem = {
|
||||||
|
Type: string,
|
||||||
|
Reason: string,
|
||||||
|
Message: string,
|
||||||
|
Count: number,
|
||||||
|
ObjectKind: string,
|
||||||
|
ObjectName: string,
|
||||||
|
Namespace: string,
|
||||||
|
FirstSeen: string,
|
||||||
|
LastSeen: string,
|
||||||
|
Age: string,
|
||||||
|
}
|
||||||
|
const [isEventsOpen, setIsEventsOpen] = useState<boolean>(false)
|
||||||
|
const [isLoadingEvents, setIsLoadingEvents] = useState<boolean>(false)
|
||||||
|
const [events, setEvents] = useState<EventItem[]>([])
|
||||||
|
const normalizeEvent = (e: any): EventItem => ({
|
||||||
|
Type: String(e.Type ?? e.type ?? ''),
|
||||||
|
Reason: String(e.Reason ?? e.reason ?? ''),
|
||||||
|
Message: String(e.Message ?? e.message ?? ''),
|
||||||
|
Count: Number(e.Count ?? e.count ?? 0),
|
||||||
|
ObjectKind: String(e.ObjectKind ?? e.objectKind ?? e.object_kind ?? ''),
|
||||||
|
ObjectName: String(e.ObjectName ?? e.objectName ?? e.object_name ?? ''),
|
||||||
|
Namespace: String(e.Namespace ?? e.namespace ?? ''),
|
||||||
|
FirstSeen: String(e.FirstSeen ?? e.firstSeen ?? e.first_seen ?? ''),
|
||||||
|
LastSeen: String(e.LastSeen ?? e.lastSeen ?? e.last_seen ?? ''),
|
||||||
|
Age: String(e.Age ?? e.age ?? ''),
|
||||||
|
})
|
||||||
|
const openEventsPanel = async () => {
|
||||||
|
if (!params.id) return
|
||||||
|
setIsEventsOpen(true)
|
||||||
|
setIsLoadingEvents(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/cluster_events?Name=${encodeURIComponent(params.id)}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const items = Array.isArray(data) ? data.map(normalizeEvent) : []
|
||||||
|
setEvents(items)
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
setEvents([])
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
setEvents([])
|
||||||
|
} finally {
|
||||||
|
setIsLoadingEvents(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCluster = async () => {
|
const fetchCluster = async () => {
|
||||||
if (params.id) {
|
if (params.id) {
|
||||||
@@ -121,6 +191,156 @@ export default function ClusterDetail() {
|
|||||||
fetchCluster();
|
fetchCluster();
|
||||||
}, [params.id]);
|
}, [params.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUsage = async () => {
|
||||||
|
if (!params.id) return
|
||||||
|
setLoadingUsage(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/cluster_resource_usage?Name=${encodeURIComponent(params.id)}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const normalized = normalizeUsage(data)
|
||||||
|
setUsage(normalized)
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
setUsage(null)
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
setUsage(null)
|
||||||
|
} finally {
|
||||||
|
setLoadingUsage(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchUsage()
|
||||||
|
}, [params.id, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalizeUptime = (data: any): Uptime | null => {
|
||||||
|
if (!data) return null
|
||||||
|
const ClusterUptime = String(data.ClusterUptime ?? data.clusterUptime ?? data.cluster_uptime ?? '')
|
||||||
|
const LastMaintenance = String(data.LastMaintenance ?? data.lastMaintenance ?? data.last_maintenance ?? '')
|
||||||
|
const NextMaintenance = String(data.NextMaintenance ?? data.nextMaintenance ?? data.next_maintenance ?? '')
|
||||||
|
return { ClusterUptime, LastMaintenance, NextMaintenance }
|
||||||
|
}
|
||||||
|
const fetchUptime = async () => {
|
||||||
|
if (!params.id) return
|
||||||
|
setLoadingUptime(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/cluster_uptime?Name=${encodeURIComponent(params.id)}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setUptimeState(normalizeUptime(data))
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
setUptimeState(null)
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
setUptimeState(null)
|
||||||
|
} finally {
|
||||||
|
setLoadingUptime(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchUptime()
|
||||||
|
}, [params.id, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const toNumber = (v: any): number => (v == null ? 0 : Number(v))
|
||||||
|
const normalizeHealth = (data: any): Health | null => {
|
||||||
|
if (!data) return null
|
||||||
|
const h: Health = {
|
||||||
|
NodesHealthy: toNumber(data.NodesHealthy ?? data.nodesHealthy ?? data.nodes_healthy),
|
||||||
|
NodesTotal: toNumber(data.NodesTotal ?? data.nodesTotal ?? data.nodes_total),
|
||||||
|
PodsRunning: toNumber(data.PodsRunning ?? data.podsRunning ?? data.pods_running),
|
||||||
|
PodsTotal: toNumber(data.PodsTotal ?? data.podsTotal ?? data.pods_total),
|
||||||
|
Alerts: toNumber(data.Alerts ?? data.alerts),
|
||||||
|
Warnings: toNumber(data.Warnings ?? data.warnings),
|
||||||
|
Status: String(data.Status ?? data.status ?? '')
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
const fetchHealth = async () => {
|
||||||
|
if (!params.id) return
|
||||||
|
setLoadingHealth(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/cluster_health?Name=${encodeURIComponent(params.id)}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setHealthState(normalizeHealth(data))
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
setHealthState(null)
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
setHealthState(null)
|
||||||
|
} finally {
|
||||||
|
setLoadingHealth(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchHealth()
|
||||||
|
}, [params.id, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalizePerformance = (data: any): Performance | null => {
|
||||||
|
if (!data) return null
|
||||||
|
const PodStartupTime = String(data.PodStartupTime ?? data.podStartupTime ?? data.pod_startup_time ?? '')
|
||||||
|
const ApiLatency = String(data.ApiLatency ?? data.apiLatency ?? data.api_latency ?? '')
|
||||||
|
const EtcdLatency = String(data.EtcdLatency ?? data.etcdLatency ?? data.etcd_latency ?? '')
|
||||||
|
const SchedulerLatency = String(data.SchedulerLatency ?? data.schedulerLatency ?? data.scheduler_latency ?? '')
|
||||||
|
return { PodStartupTime, ApiLatency, EtcdLatency, SchedulerLatency }
|
||||||
|
}
|
||||||
|
const fetchPerformance = async () => {
|
||||||
|
if (!params.id) return
|
||||||
|
setLoadingPerformance(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/cluster_performance?Name=${encodeURIComponent(params.id)}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setPerformanceState(normalizePerformance(data))
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
setPerformanceState(null)
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
setPerformanceState(null)
|
||||||
|
} finally {
|
||||||
|
setLoadingPerformance(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchPerformance()
|
||||||
|
}, [params.id, navigate])
|
||||||
|
|
||||||
// Add a loading state check
|
// Add a loading state check
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div>Loading...</div>; // You can customize the loading screen as needed
|
return <div>Loading...</div>; // You can customize the loading screen as needed
|
||||||
@@ -169,42 +389,34 @@ export default function ClusterDetail() {
|
|||||||
Resource Usage
|
Resource Usage
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
{loadingUsage ? (
|
||||||
|
<div className="text-sm text-gray-600">Loading usage...</div>
|
||||||
|
) : usage ? (
|
||||||
|
<>
|
||||||
|
{([
|
||||||
|
{ key: 'CPU', label: 'CPU', color: 'bg-blue-600' },
|
||||||
|
{ key: 'Memory', label: 'Memory', color: 'bg-green-600' },
|
||||||
|
{ key: 'Storage', label: 'Storage', color: 'bg-purple-600' },
|
||||||
|
{ key: 'Network', label: 'Network', color: 'bg-orange-600' }
|
||||||
|
] as const).map((item) => {
|
||||||
|
const u = usage[item.key]
|
||||||
|
const pct = u.Total > 0 ? Math.min(100, Math.round((u.Used / u.Total) * 100)) : 0
|
||||||
|
return (
|
||||||
|
<div key={item.key}>
|
||||||
<div className="flex justify-between text-sm mb-1">
|
<div className="flex justify-between text-sm mb-1">
|
||||||
<span className="text-gray-600">CPU</span>
|
<span className="text-gray-600">{item.label}</span>
|
||||||
<span className="font-medium">{cluster.resourceUsage.cpu.used}/{clusterStats.resourceUsage.cpu.total} {clusterStats.resourceUsage.cpu.unit}</span>
|
<span className="font-medium">{u.Used}/{u.Total} {u.Unit}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div className="bg-blue-600 h-2 rounded-full" style={{ width: `${(clusterStats.resourceUsage.cpu.used / clusterStats.resourceUsage.cpu.total) * 100}%` }}></div>
|
<div className={`${item.color} h-2 rounded-full`} style={{ width: `${pct}%` }}></div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-gray-600">Memory</span>
|
|
||||||
<span className="font-medium">{clusterStats.resourceUsage.memory.used}/{clusterStats.resourceUsage.memory.total} {clusterStats.resourceUsage.memory.unit}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div className="bg-green-600 h-2 rounded-full" style={{ width: `${(clusterStats.resourceUsage.memory.used / clusterStats.resourceUsage.memory.total) * 100}%` }}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-gray-600">Storage</span>
|
|
||||||
<span className="font-medium">{clusterStats.resourceUsage.storage.used}/{clusterStats.resourceUsage.storage.total} {clusterStats.resourceUsage.storage.unit}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div className="bg-purple-600 h-2 rounded-full" style={{ width: `${(clusterStats.resourceUsage.storage.used / clusterStats.resourceUsage.storage.total) * 100}%` }}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-gray-600">Network</span>
|
|
||||||
<span className="font-medium">{clusterStats.resourceUsage.network.used}/{clusterStats.resourceUsage.network.total} {clusterStats.resourceUsage.network.unit}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div className="bg-orange-600 h-2 rounded-full" style={{ width: `${(clusterStats.resourceUsage.network.used / clusterStats.resourceUsage.network.total) * 100}%` }}></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-600">Usage data not available.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -215,22 +427,28 @@ export default function ClusterDetail() {
|
|||||||
Performance Metrics
|
Performance Metrics
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{loadingPerformance ? (
|
||||||
|
<div className="col-span-2 text-sm text-gray-600">Loading performance...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
<div className="text-2xl font-bold text-gray-900">{clusterStats.performance.podStartupTime}</div>
|
<div className="text-2xl font-bold text-gray-900">{performanceState?.PodStartupTime || '-'}</div>
|
||||||
<div className="text-sm text-gray-600">Pod Startup Time</div>
|
<div className="text-sm text-gray-600">Pod Startup Time</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
<div className="text-2xl font-bold text-gray-900">{clusterStats.performance.apiLatency}</div>
|
<div className="text-2xl font-bold text-gray-900">{performanceState?.ApiLatency || '-'}</div>
|
||||||
<div className="text-sm text-gray-600">API Latency</div>
|
<div className="text-sm text-gray-600">API Latency</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
<div className="text-2xl font-bold text-gray-900">{clusterStats.performance.etcdLatency}</div>
|
<div className="text-2xl font-bold text-gray-900">{performanceState?.EtcdLatency || '-'}</div>
|
||||||
<div className="text-sm text-gray-600">etcd Latency</div>
|
<div className="text-sm text-gray-600">etcd Latency</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
<div className="text-2xl font-bold text-gray-900">{clusterStats.performance.schedulerLatency}</div>
|
<div className="text-2xl font-bold text-gray-900">{performanceState?.SchedulerLatency || '-'}</div>
|
||||||
<div className="text-sm text-gray-600">Scheduler Latency</div>
|
<div className="text-sm text-gray-600">Scheduler Latency</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,22 +462,30 @@ export default function ClusterDetail() {
|
|||||||
Cluster Health
|
Cluster Health
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{loadingHealth ? (
|
||||||
|
<div className="text-sm text-gray-600">Loading health...</div>
|
||||||
|
) : healthState ? (
|
||||||
|
<>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-600">Nodes</span>
|
<span className="text-gray-600">Nodes</span>
|
||||||
<span className="font-medium text-green-600">{clusterStats.health.nodesHealthy}/{clusterStats.health.nodesTotal} Healthy</span>
|
<span className="font-medium text-green-600">{healthState.NodesHealthy}/{healthState.NodesTotal} Healthy</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-600">Pods</span>
|
<span className="text-gray-600">Pods</span>
|
||||||
<span className="font-medium text-blue-600">{clusterStats.health.podsRunning}/{clusterStats.health.podsTotal} Running</span>
|
<span className="font-medium text-blue-600">{healthState.PodsRunning}/{healthState.PodsTotal} Running</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-600">Alerts</span>
|
<span className="text-gray-600">Alerts</span>
|
||||||
<span className="font-medium text-red-600">{clusterStats.health.alerts} Active</span>
|
<span className="font-medium text-red-600">{healthState.Alerts} Active</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-600">Warnings</span>
|
<span className="text-gray-600">Warnings</span>
|
||||||
<span className="font-medium text-yellow-600">{clusterStats.health.warnings} Active</span>
|
<span className="font-medium text-yellow-600">{healthState.Warnings} Active</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-600">Health data not available.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -270,18 +496,24 @@ export default function ClusterDetail() {
|
|||||||
Uptime & Maintenance
|
Uptime & Maintenance
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{loadingUptime ? (
|
||||||
|
<div className="text-sm text-gray-600">Loading uptime...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-gray-600 mb-1">Cluster Uptime</div>
|
<div className="text-sm text-gray-600 mb-1">Cluster Uptime</div>
|
||||||
<div className="font-medium text-gray-900">{clusterStats.uptime.clusterUptime}</div>
|
<div className="font-medium text-gray-900">{uptimeState?.ClusterUptime || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-gray-600 mb-1">Last Maintenance</div>
|
<div className="text-sm text-gray-600 mb-1">Last Maintenance</div>
|
||||||
<div className="font-medium text-gray-900">{clusterStats.uptime.lastMaintenance}</div>
|
<div className="font-medium text-gray-900">{uptimeState?.LastMaintenance || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-gray-600 mb-1">Next Maintenance</div>
|
<div className="text-sm text-gray-600 mb-1">Next Maintenance</div>
|
||||||
<div className="font-medium text-gray-900">{clusterStats.uptime.nextMaintenance}</div>
|
<div className="font-medium text-gray-900">{uptimeState?.NextMaintenance || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -292,15 +524,11 @@ export default function ClusterDetail() {
|
|||||||
Quick Actions
|
Quick Actions
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button className="w-full text-left p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
<button onClick={() => params.id && downloadKubeconfig(params.id)} className="w-full text-left p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
||||||
<div className="font-medium text-gray-900">View Cluster Metrics</div>
|
|
||||||
<div className="text-sm text-gray-600">Monitor CPU, memory, and network usage</div>
|
|
||||||
</button>
|
|
||||||
<button className="w-full text-left p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
|
||||||
<div className="font-medium text-gray-900">Download kubeconfig</div>
|
<div className="font-medium text-gray-900">Download kubeconfig</div>
|
||||||
<div className="text-sm text-gray-600">Get cluster access credentials</div>
|
<div className="text-sm text-gray-600">Get cluster access credentials</div>
|
||||||
</button>
|
</button>
|
||||||
<button className="w-full text-left p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
<button onClick={openEventsPanel} className="w-full text-left p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
||||||
<div className="font-medium text-gray-900">View Events</div>
|
<div className="font-medium text-gray-900">View Events</div>
|
||||||
<div className="text-sm text-gray-600">Check cluster and pod events</div>
|
<div className="text-sm text-gray-600">Check cluster and pod events</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -330,6 +558,50 @@ export default function ClusterDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Events Right Panel */}
|
||||||
|
{isEventsOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Cluster Events</h3>
|
||||||
|
<button onClick={() => setIsEventsOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoadingEvents ? (
|
||||||
|
<div className="p-4 text-sm text-gray-600">Loading events...</div>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-gray-600">No events found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{events.map((e, idx) => (
|
||||||
|
<div key={idx} className="p-4 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${e.Type === 'Warning' ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'}`}>{e.Type}</span>
|
||||||
|
<span className="text-xs text-gray-500">{e.Age}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-medium text-gray-900">{e.Reason}</div>
|
||||||
|
<div className="mt-1 text-gray-700 break-words">{e.Message}</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-gray-600">
|
||||||
|
<div><span className="font-medium">Object:</span> {e.ObjectKind}/{e.ObjectName}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {e.Namespace || '-'}</div>
|
||||||
|
<div><span className="font-medium">Count:</span> {e.Count}</div>
|
||||||
|
<div><span className="font-medium">First Seen:</span> {e.FirstSeen}</div>
|
||||||
|
<div><span className="font-medium">Last Seen:</span> {e.LastSeen}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsEventsOpen(false)} className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,246 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
export default function ConfigMaps() {
|
export default function ConfigMaps() {
|
||||||
const configmaps = [
|
const navigate = useNavigate()
|
||||||
{
|
interface ConfigMapItem {
|
||||||
name: 'app-config',
|
Name: string
|
||||||
namespace: 'default',
|
Namespace: string
|
||||||
data: 3,
|
Data: string | number
|
||||||
age: '2h',
|
Age: string
|
||||||
labels: 'app=web'
|
Labels: string
|
||||||
},
|
}
|
||||||
{
|
|
||||||
name: 'database-config',
|
interface Namespace {
|
||||||
namespace: 'default',
|
Name: string
|
||||||
data: 5,
|
Status: string
|
||||||
age: '1d',
|
Age: string
|
||||||
labels: 'app=database'
|
}
|
||||||
},
|
|
||||||
{
|
const [configmaps, setConfigMaps] = useState<ConfigMapItem[]>([])
|
||||||
name: 'redis-config',
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
namespace: 'default',
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
data: 2,
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
age: '3h',
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
labels: 'app=redis'
|
// View sidebar
|
||||||
},
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
]
|
const [selectedForManifest, setSelectedForManifest] = useState<ConfigMapItem | null>(null)
|
||||||
|
const [manifest, setManifest] = useState<string>('')
|
||||||
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
|
// Create sidebar
|
||||||
|
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [createManifest, setCreateManifest] = useState<string>('')
|
||||||
|
const isCreatingRef = useRef<boolean>(false)
|
||||||
|
// Edit sidebar
|
||||||
|
// Edit sidebar (disabled for now)
|
||||||
|
// const [isEditSidebarOpen, setIsEditSidebarOpen] = useState<boolean>(false)
|
||||||
|
// const [editManifest, setEditManifest] = useState<string>('')
|
||||||
|
// const isEditingRef = useRef<boolean>(false)
|
||||||
|
// const [selectedForEdit, setSelectedForEdit] = useState<ConfigMapItem | null>(null)
|
||||||
|
// Delete modal
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||||
|
const isDeletingRef = useRef<boolean>(false)
|
||||||
|
const [selectedForDelete, setSelectedForDelete] = useState<ConfigMapItem | null>(null)
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setNamespaces(data || [])
|
||||||
|
if (data && data.length > 0 && !selectedNamespace) setSelectedNamespace(data[0].Name)
|
||||||
|
} else if (response.status === 401) { navigate('/login') }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch namespaces', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchConfigMaps = async (namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_configmap?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_configmap?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setConfigMaps(data || [])
|
||||||
|
} else if (response.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch configmaps')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch configmaps', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (ns: string) => {
|
||||||
|
setSelectedNamespace(ns)
|
||||||
|
fetchConfigMaps(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
|
try { return btoa(unescape(encodeURIComponent(input))) } catch (_err) {
|
||||||
|
const bytes = new TextEncoder().encode(input)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View
|
||||||
|
const handleViewClick = async (cm: ConfigMapItem) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
if (isLoadingManifestRef.current) return
|
||||||
|
isLoadingManifestRef.current = true
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
setSelectedForManifest(cm)
|
||||||
|
setIsLoadingManifest(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8082/configmap_manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Namespace: cm.Namespace, Configmapname: cm.Name })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setManifest(typeof data === 'string' ? data.replace(/\\n/g, '\n') : JSON.stringify(data, null, 2))
|
||||||
|
} else if (res.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await res.json()
|
||||||
|
setManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch configmap manifest', err)
|
||||||
|
setManifest('Failed to fetch manifest: ' + err)
|
||||||
|
} finally {
|
||||||
|
isLoadingManifestRef.current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create
|
||||||
|
const openCreate = () => { setIsCreateSidebarOpen(true); setCreateManifest('') }
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (!createManifest.trim() || !clusterName) { alert('Please enter a configmap manifest'); return }
|
||||||
|
if (isCreatingRef.current) return
|
||||||
|
isCreatingRef.current = true
|
||||||
|
try {
|
||||||
|
const encoded = encodeBase64Utf8(createManifest)
|
||||||
|
const response = await fetch('http://localhost:8082/configmap_create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Manifest: encoded })
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsCreateSidebarOpen(false)
|
||||||
|
setCreateManifest('')
|
||||||
|
await fetchConfigMaps(selectedNamespace)
|
||||||
|
alert('ConfigMap created successfully!')
|
||||||
|
} else if (response.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to create configmap: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create configmap', err)
|
||||||
|
alert('Failed to create configmap: ' + err)
|
||||||
|
} finally {
|
||||||
|
isCreatingRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
// const openEdit = async (cm: ConfigMapItem) => { /* disabled */ }
|
||||||
|
// const submitEdit = async () => { /* disabled */ }
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const openDelete = (cm: ConfigMapItem) => { setSelectedForDelete(cm); setIsDeleteModalOpen(true) }
|
||||||
|
const closeDelete = () => { setIsDeleteModalOpen(false); setSelectedForDelete(null) }
|
||||||
|
const submitDelete = async () => {
|
||||||
|
if (!selectedForDelete || !clusterName) return
|
||||||
|
if (isDeletingRef.current) return
|
||||||
|
isDeletingRef.current = true
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/configmap_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(selectedForDelete.Namespace)}&configmapName=${encodeURIComponent(selectedForDelete.Name)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
closeDelete()
|
||||||
|
await fetchConfigMaps(selectedNamespace)
|
||||||
|
alert('ConfigMap deleted successfully')
|
||||||
|
} else if (response.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to delete configmap: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete configmap', err)
|
||||||
|
alert('Failed to delete configmap: ' + err)
|
||||||
|
} finally {
|
||||||
|
isDeletingRef.current = false
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('selectedCluster')
|
||||||
|
if (stored) setClusterName(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { if (clusterName) fetchNamespaces() }, [clusterName])
|
||||||
|
useEffect(() => { if (selectedNamespace && clusterName) fetchConfigMaps(selectedNamespace) }, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen ? 'mr-96' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">ConfigMaps</h1>
|
<h1 className="text-2xl font-semibold">ConfigMaps</h1>
|
||||||
<p className="text-sm text-gray-600">Manage configuration data for applications.</p>
|
<p className="text-sm text-gray-600">Manage configuration data for applications.</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-lg font-medium">ConfigMap List</h2>
|
<h2 className="text-lg font-medium">ConfigMap List</h2>
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
Create ConfigMap
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">Namespace:</label>
|
||||||
</button>
|
<select id="namespace-select" value={selectedNamespace} onChange={(e) => handleNamespaceChange(e.target.value)} className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>{ns.Name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={openCreate} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">Create ConfigMap</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading configmaps...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -53,26 +253,115 @@ export default function ConfigMaps() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{configmaps.map((configmap) => (
|
{configmaps.map((cm) => (
|
||||||
<tr key={configmap.name} className="hover:bg-gray-50">
|
<tr key={cm.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{configmap.name}</div>
|
<div className="text-sm font-medium text-gray-900">{cm.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{configmap.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cm.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{configmap.data}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cm.Data}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{configmap.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cm.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{configmap.labels}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cm.Labels}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button onClick={() => handleViewClick(cm)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
||||||
<button className="text-green-600 hover:text-green-900 mr-3">Edit</button>
|
<button onClick={() => openDelete(cm)} className="text-red-600 hover:text-red-900">Delete</button>
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* View Manifest Sidebar */}
|
||||||
|
{isSidebarOpen && selectedForManifest && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">ConfigMap Manifest</h3>
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedForManifest?.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><div className="text-white">Loading manifest...</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">{manifest}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create ConfigMap Sidebar */}
|
||||||
|
{isCreateSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Create ConfigMap</h3>
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-blue-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Instructions:</p>
|
||||||
|
<p>Paste your ConfigMap YAML. It will be base64-encoded and sent to the API.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<textarea value={createManifest} onChange={(e) => setCreateManifest(e.target.value)} placeholder="Paste your configmap manifest here (YAML format)..." className="w-full h-full p-4 text-xs font-mono text-green-400 bg-gray-900 border-none resize-none focus:outline-none placeholder-gray-500" style={{ fontFamily: 'Monaco, Menlo, \"Ubuntu Mono\", monospace' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitCreate} disabled={isCreatingRef.current || !createManifest.trim()} className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">Create ConfigMap</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit ConfigMap Sidebar - disabled */}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{isDeleteModalOpen && selectedForDelete && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Delete ConfigMap</h3>
|
||||||
|
<button onClick={closeDelete} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Are you sure you want to delete <span className="font-medium">{selectedForDelete.Name}</span> in namespace <span className="font-medium">{selectedForDelete.Namespace}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeDelete} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitDelete} disabled={isDeleting} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isDeleting ? 'Deleting...' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ export default function CreateCluster() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setClusters(data || [])
|
setClusters(data || [])
|
||||||
localStorage.setItem('clusters', JSON.stringify(data || []))
|
// localStorage.setItem('clusters', JSON.stringify(data || []))
|
||||||
|
|
||||||
// Check if any cluster is still progressing
|
// Check if any cluster is still progressing
|
||||||
const hasProgressingClusters = data.some((cluster: Cluster) => cluster.Status === 'Progressing' || cluster.Status === '' || cluster.Status === 'Missing' || cluster.Status === 'Pendding')
|
const hasProgressingClusters = data.some((cluster: Cluster) => cluster.Status === 'Progressing' || cluster.Status === '' || cluster.Status === 'Missing' || cluster.Status === 'Pendding')
|
||||||
@@ -270,6 +270,9 @@ export default function CreateCluster() {
|
|||||||
<Link
|
<Link
|
||||||
to={`/app/clusters/${cluster.Name}`}
|
to={`/app/clusters/${cluster.Name}`}
|
||||||
className="text-sm font-medium text-blue-600 hover:text-blue-900"
|
className="text-sm font-medium text-blue-600 hover:text-blue-900"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.setItem('selectedCluster', cluster.Name)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{cluster.Name}
|
{cluster.Name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,65 +1,311 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react'
|
||||||
|
|
||||||
|
interface CronJobItem {
|
||||||
|
Name: string
|
||||||
|
Namespace: string
|
||||||
|
Schedule: string
|
||||||
|
Suspend: boolean | string
|
||||||
|
LastSchedule: string
|
||||||
|
Age: string
|
||||||
|
Image: string
|
||||||
|
ConcurrencyPolicy: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function CronJobs() {
|
export default function CronJobs() {
|
||||||
const cronJobs = [
|
const [cronJobs, setCronJobs] = useState<CronJobItem[]>([])
|
||||||
{
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
name: 'backup-cronjob',
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
namespace: 'default',
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
schedule: '0 2 * * *',
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
suspend: false,
|
// View manifest
|
||||||
lastSchedule: '2h ago',
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
age: '1d',
|
const [selectedForManifest, setSelectedForManifest] = useState<CronJobItem | null>(null)
|
||||||
image: 'backup:v1.0',
|
const [manifest, setManifest] = useState<string>('')
|
||||||
concurrencyPolicy: 'Forbid'
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
},
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
{
|
// Trigger
|
||||||
name: 'cleanup-cronjob',
|
const [isTriggerModalOpen, setIsTriggerModalOpen] = useState<boolean>(false)
|
||||||
namespace: 'default',
|
const [isTriggering, setIsTriggering] = useState<boolean>(false)
|
||||||
schedule: '0 0 * * 0',
|
const [selectedForAction, setSelectedForAction] = useState<CronJobItem | null>(null)
|
||||||
suspend: false,
|
// Suspend
|
||||||
lastSchedule: '1d ago',
|
const [isSuspendModalOpen, setIsSuspendModalOpen] = useState<boolean>(false)
|
||||||
age: '3d',
|
const [isSuspending, setIsSuspending] = useState<boolean>(false)
|
||||||
image: 'cleanup:v1.0',
|
// Delete
|
||||||
concurrencyPolicy: 'Allow'
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
|
||||||
},
|
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||||
{
|
// Create CronJob
|
||||||
name: 'report-cronjob',
|
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
|
||||||
namespace: 'default',
|
const [createManifest, setCreateManifest] = useState<string>('')
|
||||||
schedule: '0 9 * * 1-5',
|
const [isCreating, setIsCreating] = useState<boolean>(false)
|
||||||
suspend: false,
|
|
||||||
lastSchedule: '1h ago',
|
const fetchNamespaces = async () => {
|
||||||
age: '2d',
|
if (!clusterName) return
|
||||||
image: 'reports:v1.2',
|
try {
|
||||||
concurrencyPolicy: 'Replace'
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
},
|
method: 'GET',
|
||||||
{
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
name: 'sync-cronjob',
|
})
|
||||||
namespace: 'default',
|
if (response.ok) {
|
||||||
schedule: '*/30 * * * *',
|
const data = await response.json()
|
||||||
suspend: true,
|
setNamespaces(data || [])
|
||||||
lastSchedule: '30m ago',
|
if (data && data.length > 0 && !selectedNamespace) setSelectedNamespace(data[0].Name)
|
||||||
age: '5d',
|
}
|
||||||
image: 'sync:v1.0',
|
} catch (err) {
|
||||||
concurrencyPolicy: 'Forbid'
|
console.error('Failed to fetch namespaces', err)
|
||||||
},
|
}
|
||||||
]
|
}
|
||||||
|
|
||||||
|
const fetchCronJobs = async (namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_cronjobs?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_cronjobs?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setCronJobs(data || [])
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch cronjobs')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch cronjobs', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (ns: string) => {
|
||||||
|
setSelectedNamespace(ns)
|
||||||
|
fetchCronJobs(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// View manifest handler
|
||||||
|
const handleViewClick = async (cj: CronJobItem) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
if ((isLoadingManifestRef as any).current) return
|
||||||
|
;(isLoadingManifestRef as any).current = true
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
setSelectedForManifest(cj)
|
||||||
|
setIsLoadingManifest(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8082/cronjobs_manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Namespace: cj.Namespace, Cronjobsname: cj.Name })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setManifest(typeof data === 'string' ? data.replace(/\\n/g, '\n') : JSON.stringify(data, null, 2))
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
setManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch cronjob manifest', err)
|
||||||
|
setManifest('Failed to fetch manifest: ' + err)
|
||||||
|
} finally {
|
||||||
|
;(isLoadingManifestRef as any).current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger handler
|
||||||
|
const openTriggerModal = (cj: CronJobItem) => {
|
||||||
|
setSelectedForAction(cj)
|
||||||
|
setIsTriggerModalOpen(true)
|
||||||
|
}
|
||||||
|
const submitTrigger = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (isTriggering) return
|
||||||
|
setIsTriggering(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8082/cronjobs_trigger', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Namespace: selectedForAction.Namespace, Cronjobsname: selectedForAction.Name })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setIsTriggerModalOpen(false)
|
||||||
|
await fetchCronJobs(selectedNamespace)
|
||||||
|
alert('CronJob triggered successfully')
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
alert('Failed to trigger cronjob: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to trigger cronjob', err)
|
||||||
|
alert('Failed to trigger cronjob: ' + err)
|
||||||
|
} finally {
|
||||||
|
setIsTriggering(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspend handler (toggle)
|
||||||
|
const openSuspendModal = (cj: CronJobItem) => {
|
||||||
|
setSelectedForAction(cj)
|
||||||
|
setIsSuspendModalOpen(true)
|
||||||
|
}
|
||||||
|
const submitSuspend = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (isSuspending) return
|
||||||
|
const currentSuspended = (selectedForAction.Suspend === true || selectedForAction.Suspend === 'true')
|
||||||
|
setIsSuspending(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8082/cronjobs_suspend', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Namespace: selectedForAction.Namespace, Cronjobsname: selectedForAction.Name, Suspend: !currentSuspended })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setIsSuspendModalOpen(false)
|
||||||
|
await fetchCronJobs(selectedNamespace)
|
||||||
|
alert(`CronJob ${!currentSuspended ? 'suspended' : 'resumed'} successfully`)
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
alert('Failed to update suspend state: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update suspend state', err)
|
||||||
|
alert('Failed to update suspend state: ' + err)
|
||||||
|
} finally {
|
||||||
|
setIsSuspending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handler
|
||||||
|
const openDeleteModal = (cj: CronJobItem) => {
|
||||||
|
setSelectedForAction(cj)
|
||||||
|
setIsDeleteModalOpen(true)
|
||||||
|
}
|
||||||
|
const submitDelete = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (isDeleting) return
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/cronjobs_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(selectedForAction.Namespace)}&cronjobName=${encodeURIComponent(selectedForAction.Name)}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setIsDeleteModalOpen(false)
|
||||||
|
await fetchCronJobs(selectedNamespace)
|
||||||
|
alert('CronJob deleted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
alert('Failed to delete cronjob: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete cronjob', err)
|
||||||
|
alert('Failed to delete cronjob: ' + err)
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
|
try {
|
||||||
|
return btoa(unescape(encodeURIComponent(input)))
|
||||||
|
} catch (_err) {
|
||||||
|
const bytes = new TextEncoder().encode(input)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCronJob = async () => {
|
||||||
|
if (!createManifest.trim() || !clusterName) { alert('Please enter a manifest'); return }
|
||||||
|
if (isCreating) return
|
||||||
|
setIsCreating(true)
|
||||||
|
try {
|
||||||
|
const encoded = encodeBase64Utf8(createManifest)
|
||||||
|
const response = await fetch('http://localhost:8082/cronjobs_create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Manifest: encoded })
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsCreateSidebarOpen(false)
|
||||||
|
setCreateManifest('')
|
||||||
|
await fetchCronJobs(selectedNamespace)
|
||||||
|
alert('CronJob created successfully!')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to create cronjob: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create cronjob', err)
|
||||||
|
alert('Failed to create cronjob: ' + err)
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('selectedCluster')
|
||||||
|
if (stored) setClusterName(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clusterName) fetchNamespaces()
|
||||||
|
}, [clusterName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNamespace && clusterName) fetchCronJobs(selectedNamespace)
|
||||||
|
}, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">CronJobs</h1>
|
<h1 className="text-2xl font-semibold">CronJobs</h1>
|
||||||
<p className="text-sm text-gray-600">Manage scheduled jobs and recurring tasks.</p>
|
<p className="text-sm text-gray-600">Manage scheduled jobs and recurring tasks.</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-lg font-medium">CronJob List</h2>
|
<h2 className="text-lg font-medium">CronJob List</h2>
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
Create CronJob
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">Namespace:</label>
|
||||||
</button>
|
<select id="namespace-select" value={selectedNamespace} onChange={(e) => handleNamespaceChange(e.target.value)} className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>{ns.Name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setIsCreateSidebarOpen(true); setCreateManifest('') }} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">Create CronJob</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading cronjobs...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -75,36 +321,168 @@ export default function CronJobs() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{cronJobs.map((cronJob) => (
|
{cronJobs.map((cj) => (
|
||||||
<tr key={cronJob.name} className="hover:bg-gray-50">
|
<tr key={cj.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{cronJob.name}</div>
|
<div className="text-sm font-medium text-gray-900">{cj.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cronJob.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cj.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">{cronJob.schedule}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">{cj.Schedule}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
cronJob.suspend ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
(cj.Suspend === true || cj.Suspend === 'true') ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
||||||
}`}>
|
}`}>
|
||||||
{cronJob.suspend ? 'True' : 'False'}
|
{(cj.Suspend === true || cj.Suspend === 'true') ? 'True' : 'False'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cronJob.lastSchedule}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cj.LastSchedule}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cronJob.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cj.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cronJob.image}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cj.Image}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cronJob.concurrencyPolicy}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{cj.ConcurrencyPolicy}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button onClick={() => handleViewClick(cj)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
||||||
<button className="text-green-600 hover:text-green-900 mr-3">Trigger</button>
|
<button onClick={() => openTriggerModal(cj)} className="text-green-600 hover:text-green-900 mr-3">Trigger</button>
|
||||||
<button className="text-orange-600 hover:text-orange-900 mr-3">Suspend</button>
|
<button onClick={() => openSuspendModal(cj)} className="text-orange-600 hover:text-orange-900 mr-3">{(cj.Suspend === true || cj.Suspend === 'true') ? 'Resume' : 'Suspend'}</button>
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
<button onClick={() => openDeleteModal(cj)} className="text-red-600 hover:text-red-900">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isCreateSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Create CronJob</h3>
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-blue-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Instructions:</p>
|
||||||
|
<p>Paste your cronjob YAML. It will be base64-encoded and sent to the API.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<textarea value={createManifest} onChange={(e) => setCreateManifest(e.target.value)} placeholder="Paste your cronjob manifest here (YAML format)..." className="w-full h-full p-4 text-xs font-mono text-green-400 bg-gray-900 border-none resize-none focus:outline-none placeholder-gray-500" style={{ fontFamily: 'Monaco, Menlo, \"Ubuntu Mono\", monospace' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={createCronJob} disabled={isCreating || !createManifest.trim()} className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isCreating ? 'Creating...' : 'Create CronJob'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manifest Sidebar */}
|
||||||
|
{isSidebarOpen && selectedForManifest && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">CronJob Manifest</h3>
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedForManifest?.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><div className="text-white">Loading manifest...</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">{manifest}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trigger Modal */}
|
||||||
|
{isTriggerModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Trigger CronJob</h3>
|
||||||
|
<button onClick={() => setIsTriggerModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Trigger run for <span className="font-medium">{selectedForAction?.Name}</span> in <span className="font-medium">{selectedForAction?.Namespace}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={() => setIsTriggerModalOpen(false)} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitTrigger} disabled={isTriggering} className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isTriggering ? 'Triggering...' : 'Trigger'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suspend Modal */}
|
||||||
|
{isSuspendModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">{(selectedForAction?.Suspend === true || selectedForAction?.Suspend === 'true') ? 'Resume CronJob' : 'Suspend CronJob'}</h3>
|
||||||
|
<button onClick={() => setIsSuspendModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Are you sure you want to {(selectedForAction?.Suspend === true || selectedForAction?.Suspend === 'true') ? 'resume' : 'suspend'} <span className="font-medium">{selectedForAction?.Name}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={() => setIsSuspendModalOpen(false)} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitSuspend} disabled={isSuspending} className="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isSuspending ? 'Updating...' : (selectedForAction?.Suspend === true || selectedForAction?.Suspend === 'true') ? 'Resume' : 'Suspend'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{isDeleteModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Delete CronJob</h3>
|
||||||
|
<button onClick={() => setIsDeleteModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Are you sure you want to delete <span className="font-medium">{selectedForAction?.Name}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={() => setIsDeleteModalOpen(false)} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitDelete} disabled={isDeleting} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isDeleting ? 'Deleting...' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,47 +1,281 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
interface DaemonSet {
|
||||||
|
Name: string
|
||||||
|
Namespace: string
|
||||||
|
Desired: string
|
||||||
|
Current: string
|
||||||
|
Ready: string
|
||||||
|
UpToDate: string
|
||||||
|
Available: string
|
||||||
|
Age: string
|
||||||
|
Image: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function DaemonSets() {
|
export default function DaemonSets() {
|
||||||
const daemonSets = [
|
const [daemonSets, setDaemonSets] = useState<DaemonSet[]>([])
|
||||||
{
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
name: 'fluentd-elasticsearch',
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
namespace: 'kube-system',
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
desired: 3,
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
current: 3,
|
|
||||||
ready: 3,
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
upToDate: 3,
|
const [selectedForManifest, setSelectedForManifest] = useState<DaemonSet | null>(null)
|
||||||
available: 3,
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
age: '2d',
|
const [manifest, setManifest] = useState<string>('')
|
||||||
image: 'fluentd:v1.14'
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
|
|
||||||
|
const [isRolloutModalOpen, setIsRolloutModalOpen] = useState<boolean>(false)
|
||||||
|
const [isRolling, setIsRolling] = useState<boolean>(false)
|
||||||
|
const isRollingRef = useRef<boolean>(false)
|
||||||
|
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||||
|
const isDeletingRef = useRef<boolean>(false)
|
||||||
|
const [selectedForAction, setSelectedForAction] = useState<DaemonSet | null>(null)
|
||||||
|
|
||||||
|
// Create DaemonSet
|
||||||
|
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [createManifest, setCreateManifest] = useState<string>('')
|
||||||
|
const [isCreating, setIsCreating] = useState<boolean>(false)
|
||||||
|
const isCreatingRef = useRef<boolean>(false)
|
||||||
|
|
||||||
|
// Encode a string to Base64 with UTF-8 safety
|
||||||
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
|
try {
|
||||||
|
return btoa(unescape(encodeURIComponent(input)))
|
||||||
|
} catch (_err) {
|
||||||
|
const bytes = new TextEncoder().encode(input)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDaemonSets = async (namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_daemonsets?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_daemonsets?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setDaemonSets(data || [])
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch daemonsets')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch daemonsets', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setNamespaces(data || [])
|
||||||
|
if (data && data.length > 0 && !selectedNamespace) setSelectedNamespace(data[0].Name)
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch namespaces')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch namespaces', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchManifest = async (ds: DaemonSet) => {
|
||||||
|
if (isLoadingManifestRef.current) return
|
||||||
|
isLoadingManifestRef.current = true
|
||||||
|
setIsLoadingManifest(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/daemonsets_manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
{
|
body: JSON.stringify({
|
||||||
name: 'kube-proxy',
|
Clustername: clusterName,
|
||||||
namespace: 'kube-system',
|
Namespace: ds.Namespace,
|
||||||
desired: 3,
|
Daemonsetsname: ds.Name
|
||||||
current: 3,
|
})
|
||||||
ready: 3,
|
})
|
||||||
upToDate: 3,
|
if (response.ok) {
|
||||||
available: 3,
|
const data = await response.json()
|
||||||
age: '2d',
|
setManifest(typeof data === 'string' ? data.replace(/\\n/g, '\n') : JSON.stringify(data, null, 2))
|
||||||
image: 'kube-proxy:v1.28.0'
|
} else {
|
||||||
},
|
const data = await response.json()
|
||||||
]
|
setManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch daemonset manifest', err)
|
||||||
|
setManifest('Failed to fetch manifest: ' + err)
|
||||||
|
} finally {
|
||||||
|
isLoadingManifestRef.current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (ns: string) => {
|
||||||
|
setSelectedNamespace(ns)
|
||||||
|
fetchDaemonSets(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewClick = (ds: DaemonSet) => {
|
||||||
|
setSelectedForManifest(ds)
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
fetchManifest(ds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openRolloutModal = (ds: DaemonSet) => {
|
||||||
|
setSelectedForAction(ds)
|
||||||
|
setIsRolloutModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitRolloutRestart = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (isRollingRef.current) return
|
||||||
|
isRollingRef.current = true
|
||||||
|
setIsRolling(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/daemonsets_rollout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
Clustername: clusterName,
|
||||||
|
Namespace: selectedForAction.Namespace,
|
||||||
|
Daemonsetsname: selectedForAction.Name,
|
||||||
|
Action: 'restart'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsRolloutModalOpen(false)
|
||||||
|
await fetchDaemonSets(selectedNamespace)
|
||||||
|
alert('Rollout restart triggered successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to rollout restart: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to rollout restart', err)
|
||||||
|
alert('Failed to rollout restart: ' + err)
|
||||||
|
} finally {
|
||||||
|
isRollingRef.current = false
|
||||||
|
setIsRolling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDeleteModal = (ds: DaemonSet) => {
|
||||||
|
setSelectedForAction(ds)
|
||||||
|
setIsDeleteModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDelete = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (isDeletingRef.current) return
|
||||||
|
isDeletingRef.current = true
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/daemonsets_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(selectedForAction.Namespace)}&daemonsetsName=${encodeURIComponent(selectedForAction.Name)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsDeleteModalOpen(false)
|
||||||
|
await fetchDaemonSets(selectedNamespace)
|
||||||
|
alert('DaemonSet deleted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to delete daemonset: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete daemonset', err)
|
||||||
|
alert('Failed to delete daemonset: ' + err)
|
||||||
|
} finally {
|
||||||
|
isDeletingRef.current = false
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('selectedCluster')
|
||||||
|
if (stored) setClusterName(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clusterName) fetchNamespaces()
|
||||||
|
}, [clusterName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNamespace && clusterName) fetchDaemonSets(selectedNamespace)
|
||||||
|
}, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">DaemonSets</h1>
|
<h1 className="text-2xl font-semibold">DaemonSets</h1>
|
||||||
<p className="text-sm text-gray-600">Manage daemon sets that run on every node.</p>
|
<p className="text-sm text-gray-600">Manage daemon sets that run on every node.</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-lg font-medium">DaemonSet List</h2>
|
<h2 className="text-lg font-medium">DaemonSet List</h2>
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">Namespace:</label>
|
||||||
|
<select id="namespace-select" value={selectedNamespace} onChange={(e) => handleNamespaceChange(e.target.value)} className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>{ns.Name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsCreateSidebarOpen(true); setCreateManifest('') }}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
|
||||||
|
>
|
||||||
Create DaemonSet
|
Create DaemonSet
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading daemonsets...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -59,29 +293,179 @@ export default function DaemonSets() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{daemonSets.map((ds) => (
|
{daemonSets.map((ds) => (
|
||||||
<tr key={ds.name} className="hover:bg-gray-50">
|
<tr key={ds.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{ds.name}</div>
|
<div className="text-sm font-medium text-gray-900">{ds.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.desired}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.Desired}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.current}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.Current}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.ready}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.Ready}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.upToDate}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.UpToDate}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.available}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.Available}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.image}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ds.Image}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button onClick={() => handleViewClick(ds)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
||||||
<button className="text-orange-600 hover:text-orange-900 mr-3">Rollout</button>
|
<button onClick={() => openRolloutModal(ds)} className="text-orange-600 hover:text-orange-900 mr-3">Rollout</button>
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
<button onClick={() => openDeleteModal(ds)} className="text-red-600 hover:text-red-900">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar for DaemonSet Manifest */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">DaemonSet Manifest</h3>
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedForManifest?.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><div className="text-white">Loading manifest...</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">{manifest}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create DaemonSet Sidebar */}
|
||||||
|
{isCreateSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Create DaemonSet</h3>
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-blue-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Instructions:</p>
|
||||||
|
<p>Paste your daemonset YAML. It will be base64-encoded and sent to the API.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<textarea
|
||||||
|
value={createManifest}
|
||||||
|
onChange={(e) => setCreateManifest(e.target.value)}
|
||||||
|
placeholder="Paste your daemonset manifest here (YAML format)..."
|
||||||
|
className="w-full h-full p-4 text-xs font-mono text-green-400 bg-gray-900 border-none resize-none focus:outline-none placeholder-gray-500"
|
||||||
|
style={{ fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!createManifest.trim() || !clusterName) { alert('Please enter a manifest'); return }
|
||||||
|
if (isCreatingRef.current) return
|
||||||
|
isCreatingRef.current = true
|
||||||
|
setIsCreating(true)
|
||||||
|
try {
|
||||||
|
const encoded = encodeBase64Utf8(createManifest)
|
||||||
|
const res = await fetch('http://localhost:8082/daemonsets_create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Manifest: encoded })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setIsCreateSidebarOpen(false)
|
||||||
|
setCreateManifest('')
|
||||||
|
await fetchDaemonSets(selectedNamespace)
|
||||||
|
alert('DaemonSet created successfully!')
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
alert('Failed to create daemonset: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create daemonset', err)
|
||||||
|
alert('Failed to create daemonset: ' + err)
|
||||||
|
} finally {
|
||||||
|
isCreatingRef.current = false
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isCreating || !createManifest.trim()}
|
||||||
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
{isCreating ? 'Creating...' : 'Create DaemonSet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rollout Restart Modal */}
|
||||||
|
{isRolloutModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Rollout Restart</h3>
|
||||||
|
<button onClick={() => setIsRolloutModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Trigger a rollout restart for <span className="font-medium">{selectedForAction.Name}</span> in <span className="font-medium">{selectedForAction.Namespace}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={() => setIsRolloutModalOpen(false)} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitRolloutRestart} disabled={isRolling} className="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isRolling ? 'Rolling...' : 'Confirm'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{isDeleteModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Delete DaemonSet</h3>
|
||||||
|
<button onClick={() => setIsDeleteModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Are you sure you want to delete <span className="font-medium">{selectedForAction.Name}</span> in <span className="font-medium">{selectedForAction.Namespace}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={() => setIsDeleteModalOpen(false)} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitDelete} disabled={isDeleting} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isDeleting ? 'Deleting...' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,39 +1,431 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
export default function Deployments() {
|
export default function Deployments() {
|
||||||
const deployments = [
|
|
||||||
{
|
interface Deployments {
|
||||||
name: 'nginx-deployment',
|
Name: string
|
||||||
namespace: 'default',
|
Namespace: string
|
||||||
ready: '3/3',
|
Available: string
|
||||||
upToDate: 3,
|
Replicas: string
|
||||||
available: 3,
|
Message: string
|
||||||
age: '2h',
|
Reason: string
|
||||||
image: 'nginx:1.21',
|
Ready: string
|
||||||
strategy: 'RollingUpdate'
|
UpdateToDate: string
|
||||||
|
Strategy: string
|
||||||
|
Image: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const [deployments, setDeployments] = useState<Deployments[]>([])
|
||||||
|
const [isLoadingDeployments, setIsLoadingDeployments] = useState<boolean>(false)
|
||||||
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [createPodManifest, setCreatePodManifest] = useState<string>('')
|
||||||
|
const [selectedDeploymentForManifest, setselectedDeploymentForManifest] = useState<Deployments| null>(null)
|
||||||
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
|
const [deploymentManifest, setDeploymentManifest] = useState<string>('')
|
||||||
|
// Scale / Rollout UI state
|
||||||
|
const [isScaleModalOpen, setIsScaleModalOpen] = useState<boolean>(false)
|
||||||
|
const [isRolloutModalOpen, setIsRolloutModalOpen] = useState<boolean>(false)
|
||||||
|
const [selectedDeploymentForAction, setSelectedDeploymentForAction] = useState<Deployments | null>(null)
|
||||||
|
const [scaleReplicas, setScaleReplicas] = useState<string>('')
|
||||||
|
const [isScaling, setIsScaling] = useState<boolean>(false)
|
||||||
|
const [isRollingOut, setIsRollingOut] = useState<boolean>(false)
|
||||||
|
// Delete UI state
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||||
|
|
||||||
|
// Refs to track request states more reliably
|
||||||
|
const isCreatingPodRef = useRef<boolean>(false)
|
||||||
|
const isLoadingLogsRef = useRef<boolean>(false)
|
||||||
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
|
const deletePollIntervalRef = useRef<number | null>(null)
|
||||||
|
const isScalingRef = useRef<boolean>(false)
|
||||||
|
const isRollingOutRef = useRef<boolean>(false)
|
||||||
|
const isCreatingDeploymentRef = useRef<boolean>(false)
|
||||||
|
const isDeletingRef = useRef<boolean>(false)
|
||||||
|
|
||||||
|
const fetchDeployments = async(namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingDeployments(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_deployments?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_deployments?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
{
|
})
|
||||||
name: 'app-deployment',
|
|
||||||
namespace: 'default',
|
if (response.ok) {
|
||||||
ready: '2/2',
|
const data = await response.json()
|
||||||
upToDate: 2,
|
setDeployments(data || [])
|
||||||
available: 2,
|
|
||||||
age: '4h',
|
|
||||||
image: 'app:v1.0',
|
} else {
|
||||||
strategy: 'RollingUpdate'
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch pods')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch pods', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingDeployments(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
{
|
})
|
||||||
name: 'api-deployment',
|
|
||||||
namespace: 'default',
|
if (response.ok) {
|
||||||
ready: '1/1',
|
const data = await response.json()
|
||||||
upToDate: 1,
|
setNamespaces(data || [])
|
||||||
available: 1,
|
// Set default namespace to first one if available
|
||||||
age: '1d',
|
if (data && data.length > 0 && !selectedNamespace) {
|
||||||
image: 'api:v2.1',
|
setSelectedNamespace(data[0].Name)
|
||||||
strategy: 'Recreate'
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch namespaces')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch namespaces', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchManifest = async (deployment: Deployments) => {
|
||||||
|
// Prevent duplicate requests using ref
|
||||||
|
if (isLoadingManifestRef.current) {
|
||||||
|
console.log('Manifest already loading, ignoring duplicate request')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingManifestRef.current = true
|
||||||
|
setIsLoadingManifest(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/deployment_manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
]
|
body: JSON.stringify({
|
||||||
|
Clustername: clusterName,
|
||||||
|
Namespace: deployment.Namespace,
|
||||||
|
Podname: deployment.Name
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
// If the response is already a string (YAML), use it directly
|
||||||
|
// Otherwise, format it as JSON
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
// Convert \n to actual line breaks for better formatting
|
||||||
|
setDeploymentManifest(data.replace(/\\n/g, '\n'))
|
||||||
|
} else {
|
||||||
|
setDeploymentManifest(JSON.stringify(data, null, 2))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch pod manifest')
|
||||||
|
setDeploymentManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch pod manifest', error)
|
||||||
|
setDeploymentManifest('Failed to fetch manifest: ' + error)
|
||||||
|
} finally {
|
||||||
|
isLoadingManifestRef.current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (namespace: string) => {
|
||||||
|
setSelectedNamespace(namespace)
|
||||||
|
fetchDeployments(namespace)
|
||||||
|
}
|
||||||
|
const handleCreatePodClick = () => {
|
||||||
|
setIsCreateSidebarOpen(true)
|
||||||
|
setCreatePodManifest('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode a string to Base64 with UTF-8 safety
|
||||||
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
|
try {
|
||||||
|
return btoa(unescape(encodeURIComponent(input)))
|
||||||
|
} catch (_err) {
|
||||||
|
const bytes = new TextEncoder().encode(input)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCreateSidebar = () => {
|
||||||
|
setIsCreateSidebarOpen(false)
|
||||||
|
setCreatePodManifest('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDeployment = async () => {
|
||||||
|
if (!createPodManifest.trim() || !clusterName) {
|
||||||
|
alert('Please enter a deployment manifest')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isCreatingDeploymentRef.current) return
|
||||||
|
|
||||||
|
isCreatingDeploymentRef.current = true
|
||||||
|
try {
|
||||||
|
const encodedManifest = encodeBase64Utf8(createPodManifest)
|
||||||
|
const response = await fetch('http://localhost:8082/deployment_create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
Clustername: clusterName,
|
||||||
|
Manifest: encodedManifest
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
closeCreateSidebar()
|
||||||
|
await fetchDeployments(selectedNamespace)
|
||||||
|
alert('Deployment created successfully!')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to create deployment: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create deployment', err)
|
||||||
|
alert('Failed to create deployment: ' + err)
|
||||||
|
} finally {
|
||||||
|
isCreatingDeploymentRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale handlers
|
||||||
|
const openScaleModal = (deployment: Deployments) => {
|
||||||
|
setSelectedDeploymentForAction(deployment)
|
||||||
|
// Prefill replicas safely (handles number or string, formats like "2/3" or just "3")
|
||||||
|
const replicasRaw = deployment.Replicas != null ? String(deployment.Replicas) : ''
|
||||||
|
const parts = replicasRaw.includes('/') ? replicasRaw.split('/') : []
|
||||||
|
const currentDesired = parts.length === 2 ? parts[1] : replicasRaw
|
||||||
|
const match = currentDesired ? currentDesired.match(/\d+/) : null
|
||||||
|
setScaleReplicas(match ? match[0] : '')
|
||||||
|
setIsScaleModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeScaleModal = () => {
|
||||||
|
setIsScaleModalOpen(false)
|
||||||
|
setSelectedDeploymentForAction(null)
|
||||||
|
setScaleReplicas('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitScale = async () => {
|
||||||
|
if (!selectedDeploymentForAction || !scaleReplicas.trim()) return
|
||||||
|
if (!clusterName) return
|
||||||
|
if (isScalingRef.current) return
|
||||||
|
|
||||||
|
const replicasNum = parseInt(scaleReplicas, 10)
|
||||||
|
if (Number.isNaN(replicasNum) || replicasNum < 0) {
|
||||||
|
alert('Please enter a valid replicas number')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isScalingRef.current = true
|
||||||
|
setIsScaling(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/deployment_scale', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
Clustername: clusterName,
|
||||||
|
Namespace: selectedDeploymentForAction.Namespace,
|
||||||
|
Deployment: selectedDeploymentForAction.Name,
|
||||||
|
Replicas: replicasNum
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
closeScaleModal()
|
||||||
|
await fetchDeployments(selectedNamespace)
|
||||||
|
alert('Scale request submitted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to scale deployment: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to scale deployment', err)
|
||||||
|
alert('Failed to scale deployment: ' + err)
|
||||||
|
} finally {
|
||||||
|
isScalingRef.current = false
|
||||||
|
setIsScaling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollout handlers (restart)
|
||||||
|
const openRolloutModal = (deployment: Deployments) => {
|
||||||
|
setSelectedDeploymentForAction(deployment)
|
||||||
|
setIsRolloutModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRolloutModal = () => {
|
||||||
|
setIsRolloutModalOpen(false)
|
||||||
|
setSelectedDeploymentForAction(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitRolloutRestart = async () => {
|
||||||
|
if (!selectedDeploymentForAction) return
|
||||||
|
if (!clusterName) return
|
||||||
|
if (isRollingOutRef.current) return
|
||||||
|
|
||||||
|
isRollingOutRef.current = true
|
||||||
|
setIsRollingOut(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/deployment_rollout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
Clustername: clusterName,
|
||||||
|
Namespace: selectedDeploymentForAction.Namespace,
|
||||||
|
Deployment: selectedDeploymentForAction.Name,
|
||||||
|
Action: 'restart'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
closeRolloutModal()
|
||||||
|
await fetchDeployments(selectedNamespace)
|
||||||
|
alert('Rollout restart triggered successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to rollout restart: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to rollout restart', err)
|
||||||
|
alert('Failed to rollout restart: ' + err)
|
||||||
|
} finally {
|
||||||
|
isRollingOutRef.current = false
|
||||||
|
setIsRollingOut(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handlers
|
||||||
|
const openDeleteModal = (deployment: Deployments) => {
|
||||||
|
setSelectedDeploymentForAction(deployment)
|
||||||
|
setIsDeleteModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDeleteModal = () => {
|
||||||
|
setIsDeleteModalOpen(false)
|
||||||
|
setSelectedDeploymentForAction(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDelete = async () => {
|
||||||
|
if (!selectedDeploymentForAction) return
|
||||||
|
if (!clusterName) return
|
||||||
|
if (isDeletingRef.current) return
|
||||||
|
|
||||||
|
isDeletingRef.current = true
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/deployment_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(selectedDeploymentForAction.Namespace)}&deploymenteName=${encodeURIComponent(selectedDeploymentForAction.Name)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
closeDeleteModal()
|
||||||
|
await fetchDeployments(selectedNamespace)
|
||||||
|
alert('Deployment deleted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to delete deployment: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete deployment', err)
|
||||||
|
alert('Failed to delete deployment: ' + err)
|
||||||
|
} finally {
|
||||||
|
isDeletingRef.current = false
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNamespace && clusterName) {
|
||||||
|
fetchDeployments(selectedNamespace)
|
||||||
|
}
|
||||||
|
}, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Get cluster name from localStorage
|
||||||
|
const storedClusterName = localStorage.getItem('selectedCluster')
|
||||||
|
if (storedClusterName) {
|
||||||
|
setClusterName(storedClusterName)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clusterName) {
|
||||||
|
fetchNamespaces()
|
||||||
|
}
|
||||||
|
}, [clusterName])
|
||||||
|
|
||||||
|
const handleViewClick = (deployment: Deployments) => {
|
||||||
|
setselectedDeploymentForManifest(deployment)
|
||||||
|
fetchManifest(deployment)
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSidebar = () => {
|
||||||
|
setIsSidebarOpen(false)
|
||||||
|
setselectedDeploymentForManifest(null)
|
||||||
|
setDeploymentManifest('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyManifest = () => {
|
||||||
|
navigator.clipboard.writeText(deploymentManifest).then(() => {
|
||||||
|
// You could add a toast notification here
|
||||||
|
console.log('Manifest copied to clipboard')
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy manifest: ', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen ? 'mr-96' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Deployments</h1>
|
<h1 className="text-2xl font-semibold">Deployments</h1>
|
||||||
<p className="text-sm text-gray-600">Manage application deployments and scaling.</p>
|
<p className="text-sm text-gray-600">Manage application deployments and scaling.</p>
|
||||||
@@ -42,14 +434,40 @@ export default function Deployments() {
|
|||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-medium">Deployment List</h2>
|
<div className="flex items-center space-x-4">
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<h2 className="text-lg font-medium">Deployments List</h2>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">
|
||||||
|
Namespace:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="namespace-select"
|
||||||
|
value={selectedNamespace}
|
||||||
|
onChange={(e) => handleNamespaceChange(e.target.value)}
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>
|
||||||
|
{ns.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreatePodClick}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
|
||||||
|
>
|
||||||
Create Deployment
|
Create Deployment
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoadingDeployments ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading deployments...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -66,29 +484,247 @@ export default function Deployments() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{deployments.map((deployment) => (
|
{deployments.map((deployment) => (
|
||||||
<tr key={deployment.name} className="hover:bg-gray-50">
|
<tr key={deployment.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{deployment.name}</div>
|
<div className="text-sm font-medium text-gray-900">{deployment.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.ready}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.Ready}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.upToDate}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.UpdateToDate}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.available}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.Available}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.image}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.Image}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.strategy}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{deployment.Strategy}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button
|
||||||
<button className="text-green-600 hover:text-green-900 mr-3">Scale</button>
|
onClick={() => handleViewClick(deployment)}
|
||||||
<button className="text-orange-600 hover:text-orange-900 mr-3">Rollout</button>
|
className="text-blue-600 hover:text-blue-900 mr-3"
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openScaleModal(deployment)}
|
||||||
|
className="text-green-600 hover:text-green-900 mr-3"
|
||||||
|
>
|
||||||
|
Scale
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openRolloutModal(deployment)}
|
||||||
|
className="text-orange-600 hover:text-orange-900 mr-3"
|
||||||
|
>
|
||||||
|
Rollout
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openDeleteModal(deployment)}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scale Modal */}
|
||||||
|
{isScaleModalOpen && selectedDeploymentForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Scale Deployment</h3>
|
||||||
|
<button onClick={closeScaleModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedDeploymentForAction.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedDeploymentForAction.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Replicas</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={scaleReplicas}
|
||||||
|
onChange={(e) => setScaleReplicas(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeScaleModal} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitScale} disabled={isScaling || !scaleReplicas.trim()} className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">
|
||||||
|
{isScaling ? 'Scaling...' : 'Apply'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rollout Restart Modal */}
|
||||||
|
{isRolloutModalOpen && selectedDeploymentForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Rollout Restart</h3>
|
||||||
|
<button onClick={closeRolloutModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">
|
||||||
|
Trigger a rollout restart for deployment <span className="font-medium">{selectedDeploymentForAction.Name}</span> in namespace <span className="font-medium">{selectedDeploymentForAction.Namespace}</span>?
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeRolloutModal} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitRolloutRestart} disabled={isRollingOut} className="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">
|
||||||
|
{isRollingOut ? 'Rolling...' : 'Confirm'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Deployment Sidebar */}
|
||||||
|
{isCreateSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Create Deployment</h3>
|
||||||
|
<button onClick={closeCreateSidebar} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-blue-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Instructions:</p>
|
||||||
|
<p>Paste your deployment manifest (YAML) below. It will be base64-encoded and sent to the API.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<textarea
|
||||||
|
value={createPodManifest}
|
||||||
|
onChange={(e) => setCreatePodManifest(e.target.value)}
|
||||||
|
placeholder="Paste your deployment manifest here (YAML format)..."
|
||||||
|
className="w-full h-full p-4 text-xs font-mono text-green-400 bg-gray-900 border-none resize-none focus:outline-none placeholder-gray-500"
|
||||||
|
style={{ fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button onClick={closeCreateSidebar} className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={createDeployment} disabled={!createPodManifest.trim()} className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">Create Deployment</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Delete Deployment Modal */}
|
||||||
|
{isDeleteModalOpen && selectedDeploymentForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Delete Deployment</h3>
|
||||||
|
<button onClick={closeDeleteModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">
|
||||||
|
Are you sure you want to delete deployment <span className="font-medium">{selectedDeploymentForAction.Name}</span> in namespace <span className="font-medium">{selectedDeploymentForAction.Namespace}</span>?
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeDeleteModal} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitDelete} disabled={isDeleting} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar for Pod Manifest */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
|
Deployment Manifest
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={copyManifest}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
title="Copy manifest"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={closeSidebar}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pod Info */}
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedDeploymentForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedDeploymentForManifest?.Namespace}</div>
|
||||||
|
<div><span className="font-medium">Ready:</span> {selectedDeploymentForManifest?.Ready}</div>
|
||||||
|
<div><span className="font-medium">Replicas:</span> {selectedDeploymentForManifest?.Replicas}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manifest Content - Scrollable Area */}
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-white">Loading manifest...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{deploymentManifest}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={closeSidebar}
|
||||||
|
className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
301
src/pages/HelmApps.tsx
Normal file
301
src/pages/HelmApps.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Preset = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
chart: string
|
||||||
|
repo: string
|
||||||
|
version?: string
|
||||||
|
defaults?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESETS: Preset[] = [
|
||||||
|
// Databases & Caches
|
||||||
|
{ id: 'mysql', name: 'MySQL (Bitnami)', chart: 'bitnami/mysql', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'mariadb', name: 'MariaDB (Bitnami)', chart: 'bitnami/mariadb', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'postgresql', name: 'PostgreSQL (Bitnami)', chart: 'bitnami/postgresql', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'mongodb', name: 'MongoDB (Bitnami)', chart: 'bitnami/mongodb', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'redis', name: 'Redis (Bitnami)', chart: 'bitnami/redis', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'minio', name: 'MinIO (MinIO)', chart: 'minio/minio', repo: 'https://charts.min.io/' },
|
||||||
|
|
||||||
|
// Messaging & Streaming
|
||||||
|
{ id: 'rabbitmq', name: 'RabbitMQ (Bitnami)', chart: 'bitnami/rabbitmq', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'kafka', name: 'Kafka (Bitnami)', chart: 'bitnami/kafka', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'zookeeper', name: 'Zookeeper (Bitnami)', chart: 'bitnami/zookeeper', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'nats', name: 'NATS (Bitnami)', chart: 'bitnami/nats', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'emqx', name: 'EMQX (EMQ)', chart: 'emqx/emqx', repo: 'https://repos.emqx.io/charts' },
|
||||||
|
|
||||||
|
// Observability
|
||||||
|
{ id: 'kube-prometheus-stack', name: 'Kube Prometheus Stack', chart: 'prometheus-community/kube-prometheus-stack', repo: 'https://prometheus-community.github.io/helm-charts' },
|
||||||
|
{ id: 'prometheus', name: 'Prometheus (prometheus-community)', chart: 'prometheus-community/prometheus', repo: 'https://prometheus-community.github.io/helm-charts' },
|
||||||
|
{ id: 'grafana', name: 'Grafana (grafana)', chart: 'grafana/grafana', repo: 'https://grafana.github.io/helm-charts' },
|
||||||
|
{ id: 'loki', name: 'Loki (grafana)', chart: 'grafana/loki', repo: 'https://grafana.github.io/helm-charts' },
|
||||||
|
{ id: 'tempo', name: 'Tempo (grafana)', chart: 'grafana/tempo', repo: 'https://grafana.github.io/helm-charts' },
|
||||||
|
{ id: 'jaeger', name: 'Jaeger (jaegertracing)', chart: 'jaegertracing/jaeger', repo: 'https://jaegertracing.github.io/helm-charts' },
|
||||||
|
|
||||||
|
// Ingress & Certs
|
||||||
|
{ id: 'ingress-nginx', name: 'NGINX Ingress (ingress-nginx)', chart: 'ingress-nginx/ingress-nginx', repo: 'https://kubernetes.github.io/ingress-nginx' },
|
||||||
|
{ id: 'cert-manager', name: 'cert-manager (jetstack)', chart: 'jetstack/cert-manager', repo: 'https://charts.jetstack.io' },
|
||||||
|
|
||||||
|
// Search & Analytics
|
||||||
|
{ id: 'elasticsearch', name: 'Elasticsearch (Bitnami)', chart: 'bitnami/elasticsearch', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'kibana', name: 'Kibana (Bitnami)', chart: 'bitnami/kibana', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
|
||||||
|
// Identity & Security
|
||||||
|
{ id: 'keycloak', name: 'Keycloak (Bitnami)', chart: 'bitnami/keycloak', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
{ id: 'vault', name: 'HashiCorp Vault', chart: 'hashicorp/vault', repo: 'https://helm.releases.hashicorp.com' },
|
||||||
|
|
||||||
|
// CI/CD & GitOps
|
||||||
|
{ id: 'argo-cd', name: 'Argo CD', chart: 'argo/argo-cd', repo: 'https://argoproj.github.io/argo-helm' },
|
||||||
|
{ id: 'jenkins', name: 'Jenkins (JenkinsCI)', chart: 'jenkinsci/jenkins', repo: 'https://charts.jenkins.io' },
|
||||||
|
|
||||||
|
// Registries & Platforms
|
||||||
|
{ id: 'harbor', name: 'Harbor (goharbor)', chart: 'goharbor/harbor', repo: 'https://helm.goharbor.io' },
|
||||||
|
|
||||||
|
// CMS & Apps
|
||||||
|
{ id: 'wordpress', name: 'WordPress (Bitnami)', chart: 'bitnami/wordpress', repo: 'https://charts.bitnami.com/bitnami' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function HelmApps() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
|
const [search, setSearch] = useState<string>('')
|
||||||
|
|
||||||
|
const [presetId, setPresetId] = useState<string>('')
|
||||||
|
const [releaseName, setReleaseName] = useState<string>('')
|
||||||
|
const [chart, setChart] = useState<string>('')
|
||||||
|
const [repo, setRepo] = useState<string>('')
|
||||||
|
const [version, setVersion] = useState<string>('')
|
||||||
|
const [valuesYaml, setValuesYaml] = useState<string>('')
|
||||||
|
const [isInstalling, setIsInstalling] = useState<boolean>(false)
|
||||||
|
const isInstallingRef = useRef<boolean>(false)
|
||||||
|
|
||||||
|
const applyPreset = (id: string) => {
|
||||||
|
setPresetId(id)
|
||||||
|
const p = PRESETS.find(x => x.id === id)
|
||||||
|
if (p) {
|
||||||
|
setChart(p.chart)
|
||||||
|
setRepo(p.repo)
|
||||||
|
setVersion(p.version || '')
|
||||||
|
if (!releaseName) setReleaseName(p.id)
|
||||||
|
if (p.defaults) setValuesYaml(p.defaults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setNamespaces(data || [])
|
||||||
|
if (data && data.length > 0 && !selectedNamespace) setSelectedNamespace(data[0].Name)
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
|
try { return btoa(unescape(encodeURIComponent(input))) } catch (_err) {
|
||||||
|
const bytes = new TextEncoder().encode(input)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitInstall = async () => {
|
||||||
|
if (!clusterName || !selectedNamespace || !releaseName.trim() || !chart.trim()) {
|
||||||
|
alert('Please fill in cluster, namespace, release, and chart')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isInstallingRef.current) return
|
||||||
|
isInstallingRef.current = true
|
||||||
|
setIsInstalling(true)
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
Clustername: clusterName,
|
||||||
|
Namespace: selectedNamespace,
|
||||||
|
Release: releaseName.trim(),
|
||||||
|
Chart: chart.trim(),
|
||||||
|
Repo: repo.trim(),
|
||||||
|
}
|
||||||
|
if (version.trim()) body.Version = version.trim()
|
||||||
|
if (valuesYaml.trim()) body.Values = encodeBase64Utf8(valuesYaml)
|
||||||
|
|
||||||
|
const res = await fetch('http://localhost:8082/helm_install', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
alert('Helm install request submitted successfully')
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({} as any))
|
||||||
|
alert('Failed to install: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to install: ' + err)
|
||||||
|
} finally {
|
||||||
|
isInstallingRef.current = false
|
||||||
|
setIsInstalling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickInstall = async (preset: Preset) => {
|
||||||
|
if (!clusterName || !selectedNamespace) { alert('Select a cluster and namespace first'); return }
|
||||||
|
if (isInstallingRef.current) return
|
||||||
|
isInstallingRef.current = true
|
||||||
|
setIsInstalling(true)
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
Clustername: clusterName,
|
||||||
|
Namespace: selectedNamespace,
|
||||||
|
Release: `${preset.id}-${Math.random().toString(36).slice(2,7)}`,
|
||||||
|
Chart: preset.chart,
|
||||||
|
Repo: preset.repo,
|
||||||
|
}
|
||||||
|
if (preset.version) body.Version = preset.version
|
||||||
|
if (preset.defaults) body.Values = encodeBase64Utf8(preset.defaults)
|
||||||
|
const res = await fetch('http://localhost:8082/helm_install', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
alert(`Installing ${preset.name}...`)
|
||||||
|
} else if (res.status === 401) { navigate('/login') }
|
||||||
|
else {
|
||||||
|
const data = await res.json().catch(() => ({} as any))
|
||||||
|
alert('Failed to install: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to install: ' + err)
|
||||||
|
} finally {
|
||||||
|
isInstallingRef.current = false
|
||||||
|
setIsInstalling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('selectedCluster')
|
||||||
|
if (stored) setClusterName(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { if (clusterName) fetchNamespaces() }, [clusterName])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Helm Applications</h1>
|
||||||
|
<p className="text-sm text-gray-600">Deploy popular applications via Helm charts.</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<h2 className="text-lg font-medium">Marketplace</h2>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">Namespace:</label>
|
||||||
|
<select id="namespace-select" value={selectedNamespace} onChange={(e) => setSelectedNamespace(e.target.value)} className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<option value="">Select Namespace</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>{ns.Name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search apps..." className="w-full md:w-64 px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* One-click marketplace grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{PRESETS.filter(p => !search || p.name.toLowerCase().includes(search.toLowerCase()) || p.id.includes(search.toLowerCase())).map(preset => (
|
||||||
|
<div key={preset.id} className="border border-gray-200 rounded-lg p-4 flex flex-col">
|
||||||
|
<div className="font-medium text-gray-900">{preset.name}</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-1 break-words">{preset.chart}</div>
|
||||||
|
<div className="text-xs text-gray-500">{preset.repo}</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button onClick={() => applyPreset(preset.id)} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 hover:bg-gray-50">Configure</button>
|
||||||
|
<button onClick={() => quickInstall(preset)} disabled={isInstalling || !selectedNamespace} className="px-3 py-1.5 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed">One-click Install</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced manual configuration */}
|
||||||
|
<div className="pt-2 border-t border-gray-200">
|
||||||
|
<div className="text-sm font-medium text-gray-700 mb-3">Advanced Configuration</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Preset</label>
|
||||||
|
<select value={presetId} onChange={(e) => applyPreset(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">Select a preset...</option>
|
||||||
|
{PRESETS.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Release Name</label>
|
||||||
|
<input value={releaseName} onChange={(e) => setReleaseName(e.target.value)} placeholder="e.g., my-mysql" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Chart</label>
|
||||||
|
<input value={chart} onChange={(e) => setChart(e.target.value)} placeholder="e.g., bitnami/mysql" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Repository URL</label>
|
||||||
|
<input value={repo} onChange={(e) => setRepo(e.target.value)} placeholder="e.g., https://charts.bitnami.com/bitnami" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Version (optional)</label>
|
||||||
|
<input value={version} onChange={(e) => setVersion(e.target.value)} placeholder="e.g., 13.1.2" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Values (YAML, optional)</label>
|
||||||
|
<textarea value={valuesYaml} onChange={(e) => setValuesYaml(e.target.value)} placeholder="# Paste custom values.yaml here" className="w-full h-48 p-3 text-xs font-mono border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button onClick={submitInstall} disabled={isInstalling} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">
|
||||||
|
{isInstalling ? 'Installing...' : 'Install via Helm'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,70 +1,283 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface JobItem {
|
||||||
|
Name: string
|
||||||
|
Namespace: string
|
||||||
|
Completions: string
|
||||||
|
Duration: string
|
||||||
|
Age: string
|
||||||
|
Image: string
|
||||||
|
Status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function Jobs() {
|
export default function Jobs() {
|
||||||
const jobs = [
|
const [jobs, setJobs] = useState<JobItem[]>([])
|
||||||
{
|
const [isLoadingJobs, setIsLoadingJobs] = useState<boolean>(false)
|
||||||
name: 'backup-job',
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
namespace: 'default',
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
completions: '1/1',
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
duration: '5m',
|
|
||||||
age: '1h',
|
// View manifest sidebar
|
||||||
image: 'backup:v1.0',
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
status: 'Complete'
|
const [selectedJobForManifest, setSelectedJobForManifest] = useState<JobItem | null>(null)
|
||||||
},
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
{
|
const [jobManifest, setJobManifest] = useState<string>('')
|
||||||
name: 'data-migration',
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
namespace: 'default',
|
|
||||||
completions: '3/3',
|
// Logs modal
|
||||||
duration: '15m',
|
const [isLogsModalOpen, setIsLogsModalOpen] = useState<boolean>(false)
|
||||||
age: '2h',
|
const [selectedJobForLogs, setSelectedJobForLogs] = useState<JobItem | null>(null)
|
||||||
image: 'migration:v2.1',
|
const [jobLogs, setJobLogs] = useState<string>('')
|
||||||
status: 'Complete'
|
const [isLoadingLogs, setIsLoadingLogs] = useState<boolean>(false)
|
||||||
},
|
const isLoadingLogsRef = useRef<boolean>(false)
|
||||||
{
|
|
||||||
name: 'cleanup-job',
|
// Delete modal
|
||||||
namespace: 'default',
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
|
||||||
completions: '0/1',
|
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||||
duration: '2m',
|
const isDeletingRef = useRef<boolean>(false)
|
||||||
age: '30m',
|
const [selectedJobForAction, setSelectedJobForAction] = useState<JobItem | null>(null)
|
||||||
image: 'cleanup:v1.0',
|
|
||||||
status: 'Running'
|
// Create sidebar
|
||||||
},
|
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
|
||||||
{
|
const [createManifest, setCreateManifest] = useState<string>('')
|
||||||
name: 'report-generator',
|
const [isCreating, setIsCreating] = useState<boolean>(false)
|
||||||
namespace: 'default',
|
const isCreatingRef = useRef<boolean>(false)
|
||||||
completions: '1/1',
|
|
||||||
duration: '10m',
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
age: '4h',
|
try { return btoa(unescape(encodeURIComponent(input))) }
|
||||||
image: 'reports:v1.2',
|
catch (_e) {
|
||||||
status: 'Complete'
|
const bytes = new TextEncoder().encode(input)
|
||||||
},
|
let binary = ''
|
||||||
{
|
const chunkSize = 0x8000
|
||||||
name: 'sync-job',
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
namespace: 'default',
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
completions: '0/1',
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
duration: '1m',
|
}
|
||||||
age: '5m',
|
return btoa(binary)
|
||||||
image: 'sync:v1.0',
|
}
|
||||||
status: 'Failed'
|
}
|
||||||
},
|
|
||||||
]
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setNamespaces(data || [])
|
||||||
|
if (data && data.length > 0 && !selectedNamespace) setSelectedNamespace(data[0].Name)
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch namespaces')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch namespaces', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchJobs = async (namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
setIsLoadingJobs(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_jobs?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_jobs?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setJobs(data || [])
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch jobs')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch jobs', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingJobs(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (ns: string) => {
|
||||||
|
setSelectedNamespace(ns)
|
||||||
|
fetchJobs(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewClick = async (job: JobItem) => {
|
||||||
|
if (isLoadingManifestRef.current) return
|
||||||
|
isLoadingManifestRef.current = true
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
setSelectedJobForManifest(job)
|
||||||
|
setIsLoadingManifest(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/jobs_manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Namespace: job.Namespace, Jobsname: job.Name })
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setJobManifest(typeof data === 'string' ? data.replace(/\\n/g, '\n') : JSON.stringify(data, null, 2))
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
setJobManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch job manifest', err)
|
||||||
|
setJobManifest('Failed to fetch manifest: ' + err)
|
||||||
|
} finally {
|
||||||
|
isLoadingManifestRef.current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogsClick = async (job: JobItem) => {
|
||||||
|
if (isLoadingLogsRef.current) return
|
||||||
|
isLoadingLogsRef.current = true
|
||||||
|
setIsLogsModalOpen(true)
|
||||||
|
setSelectedJobForLogs(job)
|
||||||
|
setIsLoadingLogs(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/jobs_logs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Namespace: job.Namespace, Jobsname: job.Name })
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setJobLogs(typeof data === 'string' ? data.replace(/\\n/g, '\n') : JSON.stringify(data, null, 2))
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
setJobLogs('Failed to fetch logs: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch job logs', err)
|
||||||
|
setJobLogs('Failed to fetch logs: ' + err)
|
||||||
|
} finally {
|
||||||
|
isLoadingLogsRef.current = false
|
||||||
|
setIsLoadingLogs(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDeleteModal = (job: JobItem) => {
|
||||||
|
setSelectedJobForAction(job)
|
||||||
|
setIsDeleteModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDelete = async () => {
|
||||||
|
if (!selectedJobForAction || !clusterName) return
|
||||||
|
if (isDeletingRef.current) return
|
||||||
|
isDeletingRef.current = true
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/jobsName_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(selectedJobForAction.Namespace)}&jobsName=${encodeURIComponent(selectedJobForAction.Name)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsDeleteModalOpen(false)
|
||||||
|
await fetchJobs(selectedNamespace)
|
||||||
|
alert('Job deleted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to delete job: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete job', err)
|
||||||
|
alert('Failed to delete job: ' + err)
|
||||||
|
} finally {
|
||||||
|
isDeletingRef.current = false
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createJob = async () => {
|
||||||
|
if (!createManifest.trim() || !clusterName) { alert('Please enter a manifest'); return }
|
||||||
|
if (isCreatingRef.current) return
|
||||||
|
isCreatingRef.current = true
|
||||||
|
setIsCreating(true)
|
||||||
|
try {
|
||||||
|
const encoded = encodeBase64Utf8(createManifest)
|
||||||
|
const response = await fetch('http://localhost:8082/jobs_create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Manifest: encoded })
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsCreateSidebarOpen(false)
|
||||||
|
setCreateManifest('')
|
||||||
|
await fetchJobs(selectedNamespace)
|
||||||
|
alert('Job created successfully!')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to create job: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create job', err)
|
||||||
|
alert('Failed to create job: ' + err)
|
||||||
|
} finally {
|
||||||
|
isCreatingRef.current = false
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedClusterName = localStorage.getItem('selectedCluster')
|
||||||
|
if (storedClusterName) setClusterName(storedClusterName)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clusterName) fetchNamespaces()
|
||||||
|
}, [clusterName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNamespace && clusterName) fetchJobs(selectedNamespace)
|
||||||
|
}, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Jobs</h1>
|
<h1 className="text-2xl font-semibold">Jobs</h1>
|
||||||
<p className="text-sm text-gray-600">Manage one-time batch jobs and tasks.</p>
|
<p className="text-sm text-gray-600">Manage one-time batch jobs and tasks.</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-lg font-medium">Job List</h2>
|
<h2 className="text-lg font-medium">Job List</h2>
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
Create Job
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">Namespace:</label>
|
||||||
</button>
|
<select id="namespace-select" value={selectedNamespace} onChange={(e) => handleNamespaceChange(e.target.value)} className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>{ns.Name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setIsCreateSidebarOpen(true); setCreateManifest('') }} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">Create Job</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoadingJobs ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading jobs...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -80,35 +293,152 @@ export default function Jobs() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{jobs.map((job) => (
|
{jobs.map((job) => (
|
||||||
<tr key={job.name} className="hover:bg-gray-50">
|
<tr key={job.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{job.name}</div>
|
<div className="text-sm font-medium text-gray-900">{job.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.completions}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.Completions}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.duration}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.Duration}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.image}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.Image}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
job.status === 'Complete' ? 'bg-green-100 text-green-800' :
|
job.Status === 'Complete' ? 'bg-green-100 text-green-800' :
|
||||||
job.status === 'Running' ? 'bg-blue-100 text-blue-800' :
|
job.Status === 'Running' ? 'bg-blue-100 text-blue-800' :
|
||||||
'bg-red-100 text-red-800'
|
'bg-red-100 text-red-800'
|
||||||
}`}>
|
}`}>
|
||||||
{job.status}
|
{job.Status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button onClick={() => handleViewClick(job)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
||||||
<button className="text-orange-600 hover:text-orange-900 mr-3">Logs</button>
|
<button onClick={() => handleLogsClick(job)} className="text-orange-600 hover:text-orange-900 mr-3">Logs</button>
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
<button onClick={() => openDeleteModal(job)} className="text-red-600 hover:text-red-900">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Manifest Sidebar */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Job Manifest</h3>
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedJobForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedJobForManifest?.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><div className="text-white">Loading manifest...</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">{jobManifest}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs Modal */}
|
||||||
|
{isLogsModalOpen && selectedJobForLogs && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-11/12 max-w-4xl shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Job Logs - {selectedJobForLogs.Name}</h3>
|
||||||
|
<button onClick={() => setIsLogsModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="bg-gray-900 rounded-lg p-4 h-96 overflow-y-auto">
|
||||||
|
{isLoadingLogs ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><div className="text-white">Loading logs...</div></div>
|
||||||
|
) : (
|
||||||
|
<pre className="text-green-400 text-sm font-mono whitespace-pre-wrap">{jobLogs}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||||
|
<button onClick={() => setIsLogsModalOpen(false)} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{isDeleteModalOpen && selectedJobForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Delete Job</h3>
|
||||||
|
<button onClick={() => setIsDeleteModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Are you sure you want to delete <span className="font-medium">{selectedJobForAction.Name}</span> in <span className="font-medium">{selectedJobForAction.Namespace}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={() => setIsDeleteModalOpen(false)} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitDelete} disabled={isDeleting} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isDeleting ? 'Deleting...' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Job Sidebar */}
|
||||||
|
{isCreateSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Create Job</h3>
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-blue-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Instructions:</p>
|
||||||
|
<p>Paste your job YAML. It will be base64-encoded and sent to the API.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<textarea value={createManifest} onChange={(e) => setCreateManifest(e.target.value)} placeholder="Paste your job manifest here (YAML format)..." className="w-full h-full p-4 text-xs font-mono text-green-400 bg-gray-900 border-none resize-none focus:outline-none placeholder-gray-500" style={{ fontFamily: 'Monaco, Menlo, \"Ubuntu Mono\", monospace' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={createJob} disabled={isCreating || !createManifest.trim()} className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isCreating ? 'Creating...' : 'Create Job'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
88
src/pages/Landing.tsx
Normal file
88
src/pages/Landing.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function Landing() {
|
||||||
|
const capabilities = [
|
||||||
|
{ title: 'Create Virtual Clusters', desc: 'Spin up isolated Kubernetes clusters in seconds, just like AWS EKS' },
|
||||||
|
{ title: 'Cluster Management', desc: 'Monitor health, resource usage, and events across all your clusters' },
|
||||||
|
{ title: 'Workload Management', desc: 'Deploy and manage Pods, Deployments, StatefulSets, and DaemonSets' },
|
||||||
|
{ title: 'Service Discovery', desc: 'Create and manage Services, ConfigMaps, and Secrets for your applications' },
|
||||||
|
{ title: 'Helm Marketplace', desc: 'One-click deployment of popular applications like MySQL, Redis, WordPress' },
|
||||||
|
{ title: 'Resource Monitoring', desc: 'Real-time CPU, Memory, Storage, and Network usage tracking' },
|
||||||
|
{ title: 'Configuration Management', desc: 'Manage ConfigMaps and Secrets across namespaces' },
|
||||||
|
{ title: 'Job Scheduling', desc: 'Run CronJobs and one-time Jobs for automated tasks' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="border-b bg-white">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<div className="text-lg font-semibold">vCluster Front</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to="/login" className="text-sm px-3 py-1.5 rounded-md border border-gray-300 hover:bg-gray-50">Sign in</Link>
|
||||||
|
<Link to="/register" className="text-sm px-3 py-1.5 rounded-md bg-blue-600 text-white hover:bg-blue-700">Sign up</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-6xl mx-auto px-4 py-12 space-y-12">
|
||||||
|
<section className="text-center space-y-4">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-gray-900">Create and Manage Virtual Kubernetes Clusters</h1>
|
||||||
|
<p className="text-gray-600 max-w-3xl mx-auto">Deploy isolated Kubernetes clusters instantly, manage workloads, monitor resources, and deploy applications with Helm - all from a single dashboard. Perfect for development, testing, and production environments.</p>
|
||||||
|
<div className="flex items-center justify-center gap-3 pt-2">
|
||||||
|
<Link to="/login" className="px-5 py-2.5 rounded-md bg-blue-600 text-white hover:bg-blue-700 text-sm">Get started</Link>
|
||||||
|
<Link to="/register" className="px-5 py-2.5 rounded-md border border-gray-300 hover:bg-gray-50 text-sm">Create account</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">Why Choose vCluster Front?</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">🚀 Instant Cluster Creation</h3>
|
||||||
|
<p className="text-gray-600">Create virtual Kubernetes clusters in seconds, not minutes. No complex setup or infrastructure management required.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">📊 Real-time Monitoring</h3>
|
||||||
|
<p className="text-gray-600">Monitor cluster health, resource usage, and events in real-time. Get insights into your cluster performance.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">🛠️ Complete Management</h3>
|
||||||
|
<p className="text-gray-600">Manage all Kubernetes resources: Pods, Deployments, Services, ConfigMaps, Secrets, and more from one interface.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">📦 Helm Marketplace</h3>
|
||||||
|
<p className="text-gray-600">Deploy popular applications with one click: MySQL, Redis, WordPress, Prometheus, and many more.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">Platform Capabilities</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{capabilities.map((capability, index) => (
|
||||||
|
<div key={index} className="border border-gray-200 rounded-lg p-4 bg-white hover:shadow-sm">
|
||||||
|
<div className="font-medium text-gray-900 mb-2">{capability.title}</div>
|
||||||
|
<div className="text-sm text-gray-600">{capability.desc}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-blue-900 mb-3">Ready to get started?</h3>
|
||||||
|
<p className="text-blue-800 mb-4">Sign up now to create your first virtual Kubernetes cluster and start managing your workloads with ease.</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to="/register" className="px-4 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700 text-sm">Create your account</Link>
|
||||||
|
<Link to="/login" className="px-4 py-2 rounded-md border border-blue-300 text-blue-700 hover:bg-blue-100 text-sm">Sign in</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t bg-white">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-6 text-xs text-gray-500">© {new Date().getFullYear()} vCluster Front - Virtual Kubernetes Management Platform</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ export default function Login() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
localStorage.setItem('auth:token', data.token)
|
localStorage.setItem('auth:token', data.token)
|
||||||
|
localStorage.setItem('auth:user', JSON.stringify({ username }))
|
||||||
navigate('/app')
|
navigate('/app')
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
@@ -1,10 +1,47 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
export default function Namespaces() {
|
export default function Namespaces() {
|
||||||
const namespaces = [
|
const [namespaces, setNamespace] = useState<Namespace[]>([])
|
||||||
{ name: 'default', status: 'Active', age: '2d', labels: 'app=web' },
|
// const namespaces = [
|
||||||
{ name: 'kube-system', status: 'Active', age: '2d', labels: 'system' },
|
// { name: 'default', status: 'Active', age: '2d', labels: 'app=web' },
|
||||||
{ name: 'monitoring', status: 'Active', age: '1d', labels: 'monitoring' },
|
// { name: 'kube-system', status: 'Active', age: '2d', labels: 'system' },
|
||||||
{ name: 'ingress-nginx', status: 'Active', age: '1d', labels: 'ingress' },
|
// { name: 'monitoring', status: 'Active', age: '1d', labels: 'monitoring' },
|
||||||
]
|
// { name: 'ingress-nginx', status: 'Active', age: '1d', labels: 'ingress' },
|
||||||
|
// ]
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/cluster_namespaces?Name=test-cluster', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setNamespace(data || [])
|
||||||
|
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch clusters')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch clusters', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNamespaces()
|
||||||
|
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -30,23 +67,21 @@ export default function Namespaces() {
|
|||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Age</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Age</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Labels</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{namespaces.map((ns) => (
|
{namespaces.map((ns) => (
|
||||||
<tr key={ns.name} className="hover:bg-gray-50">
|
<tr key={ns.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{ns.name}</div>
|
<div className="text-sm font-medium text-gray-900">{ns.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
{ns.status}
|
{ns.Status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ns.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ns.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{ns.labels}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
<button className="text-red-600 hover:text-red-900">Delete</button>
|
||||||
|
|||||||
@@ -1,69 +1,514 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react"
|
||||||
|
|
||||||
|
interface Pods {
|
||||||
|
Name: string
|
||||||
|
Namespace: string
|
||||||
|
Status: string
|
||||||
|
Restart: number
|
||||||
|
Age: string
|
||||||
|
Ready: string
|
||||||
|
Restarts: string
|
||||||
|
Ip: string
|
||||||
|
Node: string
|
||||||
|
Image: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function Pods() {
|
export default function Pods() {
|
||||||
const pods = [
|
|
||||||
{
|
const [pods, setPods] = useState<Pods[]>([])
|
||||||
name: 'nginx-deployment-66b6c48dd5',
|
const [isLoadingPods, setIsLoadingPods] = useState<boolean>(false)
|
||||||
namespace: 'default',
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
ready: '1/1',
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
status: 'Running',
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
restarts: 0,
|
const [isLogsModalOpen, setIsLogsModalOpen] = useState<boolean>(false)
|
||||||
age: '2h',
|
const [selectedPod, setSelectedPod] = useState<Pods | null>(null)
|
||||||
ip: '10.244.0.5',
|
const [podLogs, setPodLogs] = useState<string>('')
|
||||||
node: 'worker-node-1',
|
const [isLoadingLogs, setIsLoadingLogs] = useState<boolean>(false)
|
||||||
image: 'nginx:1.21'
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [selectedPodForManifest, setSelectedPodForManifest] = useState<Pods | null>(null)
|
||||||
|
const [podManifest, setPodManifest] = useState<string>('')
|
||||||
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
|
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [createPodManifest, setCreatePodManifest] = useState<string>('')
|
||||||
|
const [isCreatingPod, setIsCreatingPod] = useState<boolean>(false)
|
||||||
|
const [showDeletePodModal, setShowDeletePodModal] = useState<boolean>(false)
|
||||||
|
const [podToDelete, setPodToDelete] = useState<Pods | null>(null)
|
||||||
|
const [isDeletingPod, setIsDeletingPod] = useState<boolean>(false)
|
||||||
|
|
||||||
|
// Refs to track request states more reliably
|
||||||
|
const isCreatingPodRef = useRef<boolean>(false)
|
||||||
|
const isLoadingLogsRef = useRef<boolean>(false)
|
||||||
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
|
const deletePollIntervalRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
// Encode a string to Base64 with UTF-8 safety
|
||||||
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
|
try {
|
||||||
|
return btoa(unescape(encodeURIComponent(input)))
|
||||||
|
} catch (_err) {
|
||||||
|
// Fallback using TextEncoder for very large inputs
|
||||||
|
const bytes = new TextEncoder().encode(input)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000 // 32KB chunks to avoid call stack limits
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
{
|
})
|
||||||
name: 'redis-master-0',
|
|
||||||
namespace: 'default',
|
if (response.ok) {
|
||||||
ready: '1/1',
|
const data = await response.json()
|
||||||
status: 'Running',
|
setNamespaces(data || [])
|
||||||
restarts: 0,
|
// Set default namespace to first one if available
|
||||||
age: '1d',
|
if (data && data.length > 0 && !selectedNamespace) {
|
||||||
ip: '10.244.0.6',
|
setSelectedNamespace(data[0].Name)
|
||||||
node: 'worker-node-2',
|
}
|
||||||
image: 'redis:6.2'
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch namespaces')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch namespaces', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPods = async (namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingPods(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_pods?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_pods?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
{
|
})
|
||||||
name: 'postgres-0',
|
|
||||||
namespace: 'default',
|
if (response.ok) {
|
||||||
ready: '1/1',
|
const data = await response.json()
|
||||||
status: 'Running',
|
setPods(data || [])
|
||||||
restarts: 1,
|
|
||||||
age: '3h',
|
|
||||||
ip: '10.244.0.7',
|
} else {
|
||||||
node: 'worker-node-1',
|
const data = await response.json()
|
||||||
image: 'postgres:13'
|
console.error(data.message || 'Failed to fetch pods')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch pods', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingPods(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLogs = async (pod: Pods) => {
|
||||||
|
// Prevent duplicate requests using ref
|
||||||
|
if (isLoadingLogsRef.current) {
|
||||||
|
console.log('Logs already loading, ignoring duplicate request')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingLogsRef.current = true
|
||||||
|
setIsLoadingLogs(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/pod_logs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
{
|
body: JSON.stringify({
|
||||||
name: 'app-deployment-7d8f9c2b1a',
|
Clustername: clusterName,
|
||||||
namespace: 'default',
|
Namespace: pod.Namespace,
|
||||||
ready: '2/2',
|
Podname: pod.Name
|
||||||
status: 'Running',
|
})
|
||||||
restarts: 0,
|
})
|
||||||
age: '4h',
|
|
||||||
ip: '10.244.0.8',
|
if (response.ok) {
|
||||||
node: 'worker-node-2',
|
const data = await response.json()
|
||||||
image: 'app:v1.0'
|
setPodLogs(data)
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch pod logs')
|
||||||
|
setPodLogs('Failed to fetch logs: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch pod logs', error)
|
||||||
|
setPodLogs('Failed to fetch logs: ' + error)
|
||||||
|
} finally {
|
||||||
|
isLoadingLogsRef.current = false
|
||||||
|
setIsLoadingLogs(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (namespace: string) => {
|
||||||
|
setSelectedNamespace(namespace)
|
||||||
|
fetchPods(namespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogsClick = (pod: Pods) => {
|
||||||
|
// Prevent duplicate clicks using ref
|
||||||
|
if (isLoadingLogsRef.current) return
|
||||||
|
|
||||||
|
setSelectedPod(pod)
|
||||||
|
setIsLogsModalOpen(true)
|
||||||
|
fetchLogs(pod)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchManifest = async (pod: Pods) => {
|
||||||
|
// Prevent duplicate requests using ref
|
||||||
|
if (isLoadingManifestRef.current) {
|
||||||
|
console.log('Manifest already loading, ignoring duplicate request')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingManifestRef.current = true
|
||||||
|
setIsLoadingManifest(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/pod_manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
]
|
body: JSON.stringify({
|
||||||
|
Clustername: clusterName,
|
||||||
|
Namespace: pod.Namespace,
|
||||||
|
Podname: pod.Name
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
// If the response is already a string (YAML), use it directly
|
||||||
|
// Otherwise, format it as JSON
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
// Convert \n to actual line breaks for better formatting
|
||||||
|
setPodManifest(data.replace(/\\n/g, '\n'))
|
||||||
|
} else {
|
||||||
|
setPodManifest(JSON.stringify(data, null, 2))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch pod manifest')
|
||||||
|
setPodManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch pod manifest', error)
|
||||||
|
setPodManifest('Failed to fetch manifest: ' + error)
|
||||||
|
} finally {
|
||||||
|
isLoadingManifestRef.current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeLogsModal = () => {
|
||||||
|
setIsLogsModalOpen(false)
|
||||||
|
setSelectedPod(null)
|
||||||
|
setPodLogs('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewClick = (pod: Pods) => {
|
||||||
|
// Prevent duplicate clicks using ref
|
||||||
|
if (isLoadingManifestRef.current) return
|
||||||
|
|
||||||
|
setSelectedPodForManifest(pod)
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
fetchManifest(pod)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSidebar = () => {
|
||||||
|
setIsSidebarOpen(false)
|
||||||
|
setSelectedPodForManifest(null)
|
||||||
|
setPodManifest('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeletePod = (pod: Pods) => {
|
||||||
|
setPodToDelete(pod)
|
||||||
|
setShowDeletePodModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDeletePodModal = () => {
|
||||||
|
setShowDeletePodModal(false)
|
||||||
|
setPodToDelete(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePod = async () => {
|
||||||
|
if (!podToDelete || !clusterName) return
|
||||||
|
setIsDeletingPod(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/pod_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(podToDelete.Namespace)}&Pod=${encodeURIComponent(podToDelete.Name)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
closeDeletePodModal()
|
||||||
|
await fetchPods(selectedNamespace)
|
||||||
|
// Begin polling every 3s until the pod is gone (or timeout)
|
||||||
|
startPollingUntilPodDeleted(podToDelete.Name, podToDelete.Namespace)
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to delete pod')
|
||||||
|
alert('Failed to delete pod: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete pod', error)
|
||||||
|
alert('Failed to delete pod: ' + error)
|
||||||
|
} finally {
|
||||||
|
setIsDeletingPod(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPollingUntilPodDeleted = (podName: string, namespace: string) => {
|
||||||
|
// Clear any existing polling
|
||||||
|
if (deletePollIntervalRef.current) {
|
||||||
|
clearInterval(deletePollIntervalRef.current)
|
||||||
|
deletePollIntervalRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempts = 0
|
||||||
|
const maxAttempts = 40 // ~2 minutes
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
attempts += 1
|
||||||
|
try {
|
||||||
|
// Build URL to ensure we check the same namespace
|
||||||
|
const url = `http://localhost:8082/cluster_pods?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
// Update UI with the latest list
|
||||||
|
setPods(data || [])
|
||||||
|
const stillThere = Array.isArray(data) && data.some((p: Pods) => p.Name === podName)
|
||||||
|
if (!stillThere) {
|
||||||
|
stopDeletePolling()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-OK: still continue polling, but stop if too many attempts
|
||||||
|
if (attempts >= maxAttempts) stopDeletePolling()
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
if (attempts >= maxAttempts) stopDeletePolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
stopDeletePolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start interval
|
||||||
|
deletePollIntervalRef.current = window.setInterval(poll, 3000)
|
||||||
|
// Also run first poll immediately
|
||||||
|
void poll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDeletePolling = () => {
|
||||||
|
if (deletePollIntervalRef.current) {
|
||||||
|
clearInterval(deletePollIntervalRef.current)
|
||||||
|
deletePollIntervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyManifest = () => {
|
||||||
|
navigator.clipboard.writeText(podManifest).then(() => {
|
||||||
|
// You could add a toast notification here
|
||||||
|
console.log('Manifest copied to clipboard')
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy manifest: ', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPod = async () => {
|
||||||
|
if (!createPodManifest.trim()) {
|
||||||
|
alert('Please enter a pod manifest')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate requests using ref
|
||||||
|
if (isCreatingPodRef.current) {
|
||||||
|
console.log('Pod creation already in progress, ignoring duplicate request')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreatingPodRef.current = true
|
||||||
|
setIsCreatingPod(true)
|
||||||
|
try {
|
||||||
|
const encodedManifest = encodeBase64Utf8(createPodManifest)
|
||||||
|
const response = await fetch('http://localhost:8082/pod_create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
Clustername: clusterName,
|
||||||
|
Manifest: encodedManifest
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('Pod created successfully:', data)
|
||||||
|
// Close sidebar and refresh pods list
|
||||||
|
closeCreateSidebar()
|
||||||
|
fetchPods(selectedNamespace)
|
||||||
|
alert('Pod created successfully!')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to create pod')
|
||||||
|
alert('Failed to create pod: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create pod', error)
|
||||||
|
alert('Failed to create pod: ' + error)
|
||||||
|
} finally {
|
||||||
|
isCreatingPodRef.current = false
|
||||||
|
setIsCreatingPod(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreatePodClick = () => {
|
||||||
|
setIsCreateSidebarOpen(true)
|
||||||
|
setCreatePodManifest('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCreateSidebar = () => {
|
||||||
|
setIsCreateSidebarOpen(false)
|
||||||
|
setCreatePodManifest('')
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Get cluster name from localStorage
|
||||||
|
const storedClusterName = localStorage.getItem('selectedCluster')
|
||||||
|
if (storedClusterName) {
|
||||||
|
setClusterName(storedClusterName)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clusterName) {
|
||||||
|
fetchNamespaces()
|
||||||
|
}
|
||||||
|
}, [clusterName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNamespace && clusterName) {
|
||||||
|
fetchPods(selectedNamespace)
|
||||||
|
}
|
||||||
|
}, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
|
// Cleanup polling on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (deletePollIntervalRef.current) {
|
||||||
|
clearInterval(deletePollIntervalRef.current)
|
||||||
|
deletePollIntervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen || isCreateSidebarOpen ? 'mr-96' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Pods</h1>
|
<h1 className="text-2xl font-semibold">Pods</h1>
|
||||||
<p className="text-sm text-gray-600">Monitor and manage application pods.</p>
|
<p className="text-sm text-gray-600">Monitor and manage application pods.</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!clusterName && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-yellow-800">No Cluster Selected</h3>
|
||||||
|
<div className="mt-2 text-sm text-yellow-700">
|
||||||
|
<p>Please select a cluster from the Create Cluster page to view its pods.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{clusterName && (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-lg font-medium">Pod List</h2>
|
<h2 className="text-lg font-medium">Pod List</h2>
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">
|
||||||
|
Namespace:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="namespace-select"
|
||||||
|
value={selectedNamespace}
|
||||||
|
onChange={(e) => handleNamespaceChange(e.target.value)}
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>
|
||||||
|
{ns.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreatePodClick}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
|
||||||
|
>
|
||||||
Create Pod
|
Create Pod
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoadingPods ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading pods...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -81,33 +526,266 @@ export default function Pods() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{pods.map((pod) => (
|
{pods.map((pod) => (
|
||||||
<tr key={pod.name} className="hover:bg-gray-50">
|
<tr key={pod.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{pod.name}</div>
|
<div className="text-sm font-medium text-gray-900">{pod.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.ready}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.Ready}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
{pod.status}
|
{pod.Status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.restarts}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.Restarts}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">{pod.ip}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">{pod.Ip}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.node}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.Node}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.image}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{pod.Image}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button
|
||||||
<button className="text-orange-600 hover:text-orange-900 mr-3">Logs</button>
|
onClick={() => handleViewClick(pod)}
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
className="text-blue-600 hover:text-blue-900 mr-3"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleLogsClick(pod)}
|
||||||
|
className="text-orange-600 hover:text-orange-900 mr-3"
|
||||||
|
>
|
||||||
|
Logs
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => confirmDeletePod(pod)}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs Modal */}
|
||||||
|
{isLogsModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-11/12 max-w-4xl shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-3">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
|
Pod Logs - {selectedPod?.Name}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={closeLogsModal}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-4 text-sm text-gray-600">
|
||||||
|
<span className="font-medium">Namespace:</span> {selectedPod?.Namespace} |
|
||||||
|
<span className="font-medium ml-2">Pod:</span> {selectedPod?.Name} |
|
||||||
|
<span className="font-medium ml-2">Status:</span> {selectedPod?.Status}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-lg p-4 h-96 overflow-y-auto">
|
||||||
|
{isLoadingLogs ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-white">Loading logs...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="text-green-400 text-sm font-mono whitespace-pre-wrap">
|
||||||
|
{podLogs}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={closeLogsModal}
|
||||||
|
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar for Pod Manifest */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
|
Pod Manifest
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={copyManifest}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
title="Copy manifest"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={closeSidebar}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pod Info */}
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedPodForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedPodForManifest?.Namespace}</div>
|
||||||
|
<div><span className="font-medium">Status:</span> {selectedPodForManifest?.Status}</div>
|
||||||
|
<div><span className="font-medium">Node:</span> {selectedPodForManifest?.Node}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manifest Content - Scrollable Area */}
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-white">Loading manifest...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{podManifest}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={closeSidebar}
|
||||||
|
className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Pod Sidebar */}
|
||||||
|
{isCreateSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
|
Create Pod
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={closeCreateSidebar}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-blue-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Instructions:</p>
|
||||||
|
<p>Paste your pod manifest (YAML format) in the text area below and click Create to deploy the pod.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manifest Editor */}
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<textarea
|
||||||
|
value={createPodManifest}
|
||||||
|
onChange={(e) => setCreatePodManifest(e.target.value)}
|
||||||
|
placeholder="Paste your pod manifest here (YAML format)..."
|
||||||
|
className="w-full h-full p-4 text-xs font-mono text-green-400 bg-gray-900 border-none resize-none focus:outline-none placeholder-gray-500"
|
||||||
|
style={{ fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={closeCreateSidebar}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={createPod}
|
||||||
|
disabled={isCreatingPod || !createPodManifest.trim()}
|
||||||
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
{isCreatingPod ? 'Creating...' : 'Create Pod'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Pod Confirmation Modal */}
|
||||||
|
{showDeletePodModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-semibold">Delete Pod</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Are you sure you want to delete pod
|
||||||
|
<span className="font-semibold"> {podToDelete?.Name}</span> in namespace
|
||||||
|
<span className="font-semibold"> {podToDelete?.Namespace}</span>?
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">This action cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={closeDeletePodModal}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 disabled:opacity-50"
|
||||||
|
disabled={isDeletingPod}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={deletePod}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50"
|
||||||
|
disabled={isDeletingPod}
|
||||||
|
>
|
||||||
|
{isDeletingPod ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,55 +1,311 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
interface ReplicaSet {
|
||||||
|
Name: string
|
||||||
|
Namespace: string
|
||||||
|
Desired: string
|
||||||
|
Current: string
|
||||||
|
Ready: string
|
||||||
|
Age: string
|
||||||
|
Image: string
|
||||||
|
Labels: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReplicaSets() {
|
export default function ReplicaSets() {
|
||||||
const replicaSets = [
|
const [replicaSets, setReplicaSets] = useState<ReplicaSet[]>([])
|
||||||
{
|
const [isLoadingReplicaSets, setIsLoadingReplicaSets] = useState<boolean>(false)
|
||||||
name: 'nginx-deployment-66b6c48dd5',
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
namespace: 'default',
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
desired: 3,
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
current: 3,
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
ready: 3,
|
const [selectedReplicaSetForManifest, setSelectedReplicaSetForManifest] = useState<ReplicaSet | null>(null)
|
||||||
age: '2h',
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
image: 'nginx:1.21',
|
const [replicaSetManifest, setReplicaSetManifest] = useState<string>('')
|
||||||
labels: 'app=nginx'
|
const [isScaleModalOpen, setIsScaleModalOpen] = useState<boolean>(false)
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
|
||||||
|
const [selectedReplicaSetForAction, setSelectedReplicaSetForAction] = useState<ReplicaSet | null>(null)
|
||||||
|
const [scaleReplicas, setScaleReplicas] = useState<string>('')
|
||||||
|
const [isScaling, setIsScaling] = useState<boolean>(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const isScalingRef = useRef<boolean>(false)
|
||||||
|
const isDeletingRef = useRef<boolean>(false)
|
||||||
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
|
|
||||||
|
const fetchReplicaSets = async (namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
setIsLoadingReplicaSets(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_replicasets?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_replicasets?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
{
|
})
|
||||||
name: 'app-deployment-7d8f9c2b1a',
|
if (response.ok) {
|
||||||
namespace: 'default',
|
const data = await response.json()
|
||||||
desired: 2,
|
setReplicaSets(data || [])
|
||||||
current: 2,
|
} else {
|
||||||
ready: 2,
|
const data = await response.json()
|
||||||
age: '4h',
|
console.error(data.message || 'Failed to fetch replica sets')
|
||||||
image: 'app:v1.0',
|
}
|
||||||
labels: 'app=web'
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch replica sets', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingReplicaSets(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
{
|
})
|
||||||
name: 'api-deployment-9e4f2c1d8b',
|
if (response.ok) {
|
||||||
namespace: 'default',
|
const data = await response.json()
|
||||||
desired: 1,
|
setNamespaces(data || [])
|
||||||
current: 1,
|
if (data && data.length > 0 && !selectedNamespace) {
|
||||||
ready: 1,
|
setSelectedNamespace(data[0].Name)
|
||||||
age: '1d',
|
}
|
||||||
image: 'api:v2.1',
|
} else {
|
||||||
labels: 'app=api'
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch namespaces')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch namespaces', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchManifest = async (rs: ReplicaSet) => {
|
||||||
|
if (isLoadingManifestRef.current) return
|
||||||
|
isLoadingManifestRef.current = true
|
||||||
|
setIsLoadingManifest(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/replicaset_manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
]
|
body: JSON.stringify({
|
||||||
|
Clustername: clusterName,
|
||||||
|
Namespace: rs.Namespace,
|
||||||
|
Replicasetname: rs.Name
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
setReplicaSetManifest(data.replace(/\\n/g, '\n'))
|
||||||
|
} else {
|
||||||
|
setReplicaSetManifest(JSON.stringify(data, null, 2))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
setReplicaSetManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch replicaset manifest', err)
|
||||||
|
setReplicaSetManifest('Failed to fetch manifest: ' + err)
|
||||||
|
} finally {
|
||||||
|
isLoadingManifestRef.current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (namespace: string) => {
|
||||||
|
setSelectedNamespace(namespace)
|
||||||
|
fetchReplicaSets(namespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewClick = (rs: ReplicaSet) => {
|
||||||
|
setSelectedReplicaSetForManifest(rs)
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
fetchManifest(rs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSidebar = () => {
|
||||||
|
setIsSidebarOpen(false)
|
||||||
|
setSelectedReplicaSetForManifest(null)
|
||||||
|
setReplicaSetManifest('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyManifest = () => {
|
||||||
|
navigator.clipboard.writeText(replicaSetManifest).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openScaleModal = (rs: ReplicaSet) => {
|
||||||
|
setSelectedReplicaSetForAction(rs)
|
||||||
|
const desired = rs.Desired != null ? String(rs.Desired) : ''
|
||||||
|
const match = desired.match(/\d+/)
|
||||||
|
setScaleReplicas(match ? match[0] : '')
|
||||||
|
setIsScaleModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeScaleModal = () => {
|
||||||
|
setIsScaleModalOpen(false)
|
||||||
|
setSelectedReplicaSetForAction(null)
|
||||||
|
setScaleReplicas('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitScale = async () => {
|
||||||
|
if (!selectedReplicaSetForAction || !clusterName) return
|
||||||
|
if (isScalingRef.current) return
|
||||||
|
const replicasNum = parseInt(scaleReplicas, 10)
|
||||||
|
if (Number.isNaN(replicasNum) || replicasNum < 0) {
|
||||||
|
alert('Please enter a valid replicas number')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isScalingRef.current = true
|
||||||
|
setIsScaling(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/replicaset_scale', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
Clustername: clusterName,
|
||||||
|
Namespace: selectedReplicaSetForAction.Namespace,
|
||||||
|
Replicasetname: selectedReplicaSetForAction.Name,
|
||||||
|
Replicas: replicasNum
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
closeScaleModal()
|
||||||
|
await fetchReplicaSets(selectedNamespace)
|
||||||
|
alert('Scale request submitted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to scale replicaset: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to scale replicaset', err)
|
||||||
|
alert('Failed to scale replicaset: ' + err)
|
||||||
|
} finally {
|
||||||
|
isScalingRef.current = false
|
||||||
|
setIsScaling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDeleteModal = (rs: ReplicaSet) => {
|
||||||
|
setSelectedReplicaSetForAction(rs)
|
||||||
|
setIsDeleteModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDeleteModal = () => {
|
||||||
|
setIsDeleteModalOpen(false)
|
||||||
|
setSelectedReplicaSetForAction(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDelete = async () => {
|
||||||
|
if (!selectedReplicaSetForAction || !clusterName) return
|
||||||
|
if (isDeletingRef.current) return
|
||||||
|
isDeletingRef.current = true
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/replicaset_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(selectedReplicaSetForAction.Namespace)}&replicasetName=${encodeURIComponent(selectedReplicaSetForAction.Name)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
closeDeleteModal()
|
||||||
|
await fetchReplicaSets(selectedNamespace)
|
||||||
|
alert('ReplicaSet deleted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to delete replicaset: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete replicaset', err)
|
||||||
|
alert('Failed to delete replicaset: ' + err)
|
||||||
|
} finally {
|
||||||
|
isDeletingRef.current = false
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedClusterName = localStorage.getItem('selectedCluster')
|
||||||
|
if (storedClusterName) {
|
||||||
|
setClusterName(storedClusterName)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clusterName) {
|
||||||
|
fetchNamespaces()
|
||||||
|
}
|
||||||
|
}, [clusterName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNamespace && clusterName) {
|
||||||
|
fetchReplicaSets(selectedNamespace)
|
||||||
|
}
|
||||||
|
}, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen ? 'mr-96' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">ReplicaSets</h1>
|
<h1 className="text-2xl font-semibold">ReplicaSets</h1>
|
||||||
<p className="text-sm text-gray-600">Manage replica sets and pod scaling.</p>
|
<p className="text-sm text-gray-600">Manage replica sets and pod scaling.</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-lg font-medium">ReplicaSet List</h2>
|
<h2 className="text-lg font-medium">ReplicaSet List</h2>
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
Create ReplicaSet
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">
|
||||||
</button>
|
Namespace:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="namespace-select"
|
||||||
|
value={selectedNamespace}
|
||||||
|
onChange={(e) => handleNamespaceChange(e.target.value)}
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>
|
||||||
|
{ns.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoadingReplicaSets ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading replica sets...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -66,28 +322,154 @@ export default function ReplicaSets() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{replicaSets.map((rs) => (
|
{replicaSets.map((rs) => (
|
||||||
<tr key={rs.name} className="hover:bg-gray-50">
|
<tr key={rs.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{rs.name}</div>
|
<div className="text-sm font-medium text-gray-900">{rs.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.desired}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.Desired}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.current}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.Current}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.ready}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.Ready}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.image}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.Image}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.labels}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rs.Labels}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button
|
||||||
<button className="text-green-600 hover:text-green-900 mr-3">Scale</button>
|
onClick={() => handleViewClick(rs)}
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
className="text-blue-600 hover:text-blue-900 mr-3"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openScaleModal(rs)}
|
||||||
|
className="text-green-600 hover:text-green-900 mr-3"
|
||||||
|
>
|
||||||
|
Scale
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openDeleteModal(rs)}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar for ReplicaSet Manifest */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">ReplicaSet Manifest</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button onClick={copyManifest} className="text-gray-400 hover:text-gray-600" title="Copy manifest">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onClick={closeSidebar} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedReplicaSetForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedReplicaSetForManifest?.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-white">Loading manifest...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{replicaSetManifest}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button onClick={closeSidebar} className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scale Modal */}
|
||||||
|
{isScaleModalOpen && selectedReplicaSetForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Scale ReplicaSet</h3>
|
||||||
|
<button onClick={closeScaleModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedReplicaSetForAction.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedReplicaSetForAction.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Replicas</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={scaleReplicas}
|
||||||
|
onChange={(e) => setScaleReplicas(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeScaleModal} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitScale} disabled={isScaling || !scaleReplicas.trim()} className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">
|
||||||
|
{isScaling ? 'Scaling...' : 'Apply'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{isDeleteModalOpen && selectedReplicaSetForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Delete ReplicaSet</h3>
|
||||||
|
<button onClick={closeDeleteModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">
|
||||||
|
Are you sure you want to delete replicaset <span className="font-medium">{selectedReplicaSetForAction.Name}</span> in namespace <span className="font-medium">{selectedReplicaSetForAction.Namespace}</span>?
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeDeleteModal} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitDelete} disabled={isDeleting} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,313 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
export default function ReplicationControllers() {
|
export default function ReplicationControllers() {
|
||||||
const replicationControllers = [
|
interface ReplicationControllerItem {
|
||||||
{
|
Name: string
|
||||||
name: 'legacy-app',
|
Namespace: string
|
||||||
namespace: 'default',
|
Desired: string | number
|
||||||
desired: 2,
|
Current: string | number
|
||||||
current: 2,
|
Ready: string | number
|
||||||
ready: 2,
|
Age: string
|
||||||
age: '5d',
|
Image: string
|
||||||
image: 'legacy-app:v1.0',
|
Labels: string
|
||||||
labels: 'app=legacy'
|
}
|
||||||
},
|
|
||||||
]
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const [replicationControllers, setReplicationControllers] = useState<ReplicationControllerItem[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
|
// Manifest Sidebar
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [selectedForManifest, setSelectedForManifest] = useState<ReplicationControllerItem | null>(null)
|
||||||
|
const [manifest, setManifest] = useState<string>('')
|
||||||
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
|
// Create Sidebar
|
||||||
|
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [createManifest, setCreateManifest] = useState<string>('')
|
||||||
|
const isCreatingRef = useRef<boolean>(false)
|
||||||
|
// Scale Modal
|
||||||
|
const [isScaleModalOpen, setIsScaleModalOpen] = useState<boolean>(false)
|
||||||
|
const [scaleReplicas, setScaleReplicas] = useState<string>('')
|
||||||
|
const [isScaling, setIsScaling] = useState<boolean>(false)
|
||||||
|
const isScalingRef = useRef<boolean>(false)
|
||||||
|
// Migrate Modal
|
||||||
|
const [isMigrateModalOpen, setIsMigrateModalOpen] = useState<boolean>(false)
|
||||||
|
const [isMigrating, setIsMigrating] = useState<boolean>(false)
|
||||||
|
const isMigratingRef = useRef<boolean>(false)
|
||||||
|
// Delete Modal
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||||
|
const isDeletingRef = useRef<boolean>(false)
|
||||||
|
// Selection
|
||||||
|
const [selectedForAction, setSelectedForAction] = useState<ReplicationControllerItem | null>(null)
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setNamespaces(data || [])
|
||||||
|
if (data && data.length > 0 && !selectedNamespace) setSelectedNamespace(data[0].Name)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch namespaces', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchReplicationControllers = async (namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_replicationcontrollers?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_replicationcontrollers?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setReplicationControllers(data || [])
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch replication controllers')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch replication controllers', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (ns: string) => {
|
||||||
|
setSelectedNamespace(ns)
|
||||||
|
fetchReplicationControllers(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// View manifest
|
||||||
|
const handleViewClick = async (rc: ReplicationControllerItem) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
if (isLoadingManifestRef.current) return
|
||||||
|
isLoadingManifestRef.current = true
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
setSelectedForManifest(rc)
|
||||||
|
setIsLoadingManifest(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8082/replicationcontroller_manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Namespace: rc.Namespace, Replicationcontroller: rc.Name })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setManifest(typeof data === 'string' ? data.replace(/\\n/g, '\n') : JSON.stringify(data, null, 2))
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
setManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch replication controller manifest', err)
|
||||||
|
setManifest('Failed to fetch manifest: ' + err)
|
||||||
|
} finally {
|
||||||
|
isLoadingManifestRef.current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create
|
||||||
|
const openCreate = () => { setIsCreateSidebarOpen(true); setCreateManifest('') }
|
||||||
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
|
try { return btoa(unescape(encodeURIComponent(input))) } catch (_err) {
|
||||||
|
const bytes = new TextEncoder().encode(input)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const createReplicationController = async () => {
|
||||||
|
if (!createManifest.trim() || !clusterName) { alert('Please enter a manifest'); return }
|
||||||
|
if (isCreatingRef.current) return
|
||||||
|
isCreatingRef.current = true
|
||||||
|
try {
|
||||||
|
const encoded = encodeBase64Utf8(createManifest)
|
||||||
|
const response = await fetch('http://localhost:8082/replicationcontroller_create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Manifest: encoded })
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsCreateSidebarOpen(false)
|
||||||
|
setCreateManifest('')
|
||||||
|
await fetchReplicationControllers(selectedNamespace)
|
||||||
|
alert('Replication Controller created successfully!')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to create replication controller: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create replication controller', err)
|
||||||
|
alert('Failed to create replication controller: ' + err)
|
||||||
|
} finally {
|
||||||
|
isCreatingRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale
|
||||||
|
const openScaleModal = (rc: ReplicationControllerItem) => {
|
||||||
|
setSelectedForAction(rc)
|
||||||
|
const replicasRaw = rc.Desired != null ? String(rc.Desired) : ''
|
||||||
|
const match = replicasRaw ? replicasRaw.match(/\d+/) : null
|
||||||
|
setScaleReplicas(match ? match[0] : '')
|
||||||
|
setIsScaleModalOpen(true)
|
||||||
|
}
|
||||||
|
const closeScaleModal = () => { setIsScaleModalOpen(false); setSelectedForAction(null); setScaleReplicas('') }
|
||||||
|
const submitScale = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (!scaleReplicas.trim()) return
|
||||||
|
if (isScalingRef.current) return
|
||||||
|
const replicasNum = parseInt(scaleReplicas, 10)
|
||||||
|
if (Number.isNaN(replicasNum) || replicasNum < 0) { alert('Enter valid replicas'); return }
|
||||||
|
isScalingRef.current = true
|
||||||
|
setIsScaling(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8082/replicationcontroller_scale', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Namespace: selectedForAction.Namespace, Replicationcontroller: selectedForAction.Name, Replicas: replicasNum })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
closeScaleModal()
|
||||||
|
await fetchReplicationControllers(selectedNamespace)
|
||||||
|
alert('Scale request submitted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
alert('Failed to scale replication controller: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to scale replication controller', err)
|
||||||
|
alert('Failed to scale replication controller: ' + err)
|
||||||
|
} finally {
|
||||||
|
isScalingRef.current = false
|
||||||
|
setIsScaling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate to Deployment
|
||||||
|
const openMigrateModal = (rc: ReplicationControllerItem) => { setSelectedForAction(rc); setIsMigrateModalOpen(true) }
|
||||||
|
const closeMigrateModal = () => { setIsMigrateModalOpen(false); setSelectedForAction(null) }
|
||||||
|
const submitMigrate = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (isMigratingRef.current) return
|
||||||
|
isMigratingRef.current = true
|
||||||
|
setIsMigrating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8082/replicationcontroller_migrate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Namespace: selectedForAction.Namespace, Replicationcontroller: selectedForAction.Name })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
closeMigrateModal()
|
||||||
|
await fetchReplicationControllers(selectedNamespace)
|
||||||
|
alert('Migration to Deployment triggered successfully')
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
alert('Failed to migrate: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to migrate', err)
|
||||||
|
alert('Failed to migrate: ' + err)
|
||||||
|
} finally {
|
||||||
|
isMigratingRef.current = false
|
||||||
|
setIsMigrating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const openDeleteModal = (rc: ReplicationControllerItem) => { setSelectedForAction(rc); setIsDeleteModalOpen(true) }
|
||||||
|
const closeDeleteModal = () => { setIsDeleteModalOpen(false); setSelectedForAction(null) }
|
||||||
|
const submitDelete = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (isDeletingRef.current) return
|
||||||
|
isDeletingRef.current = true
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/replicationcontroller_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(selectedForAction.Namespace)}&replicationcontrollerName=${encodeURIComponent(selectedForAction.Name)}`
|
||||||
|
const response = await fetch(url, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` } })
|
||||||
|
if (response.ok) {
|
||||||
|
closeDeleteModal()
|
||||||
|
await fetchReplicationControllers(selectedNamespace)
|
||||||
|
alert('Replication Controller deleted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to delete replication controller: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete replication controller', err)
|
||||||
|
alert('Failed to delete replication controller: ' + err)
|
||||||
|
} finally {
|
||||||
|
isDeletingRef.current = false
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('selectedCluster')
|
||||||
|
if (stored) setClusterName(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { if (clusterName) fetchNamespaces() }, [clusterName])
|
||||||
|
useEffect(() => { if (selectedNamespace && clusterName) fetchReplicationControllers(selectedNamespace) }, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen ? 'mr-96' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Replication Controllers</h1>
|
<h1 className="text-2xl font-semibold">Replication Controllers</h1>
|
||||||
<p className="text-sm text-gray-600">Manage legacy replication controllers (deprecated in favor of Deployments).</p>
|
<p className="text-sm text-gray-600">Manage legacy replication controllers (deprecated in favor of Deployments).</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-lg font-medium">Replication Controller List</h2>
|
<h2 className="text-lg font-medium">Replication Controller List</h2>
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
Create Replication Controller
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">Namespace:</label>
|
||||||
</button>
|
<select id="namespace-select" value={selectedNamespace} onChange={(e) => handleNamespaceChange(e.target.value)} className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>{ns.Name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={openCreate} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">Create Replication Controller</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading replication controllers...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -46,29 +324,172 @@ export default function ReplicationControllers() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{replicationControllers.map((rc) => (
|
{replicationControllers.map((rc) => (
|
||||||
<tr key={rc.name} className="hover:bg-gray-50">
|
<tr key={rc.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{rc.name}</div>
|
<div className="text-sm font-medium text-gray-900">{rc.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.desired}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.Desired}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.current}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.Current}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.ready}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.Ready}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.image}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.Image}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.labels}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{rc.Labels}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button onClick={() => handleViewClick(rc)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
||||||
<button className="text-green-600 hover:text-green-900 mr-3">Scale</button>
|
<button onClick={() => openScaleModal(rc)} className="text-green-600 hover:text-green-900 mr-3">Scale</button>
|
||||||
<button className="text-orange-600 hover:text-orange-900 mr-3">Migrate to Deployment</button>
|
<button onClick={() => openMigrateModal(rc)} className="text-orange-600 hover:text-orange-900 mr-3">Migrate to Deployment</button>
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
<button onClick={() => openDeleteModal(rc)} className="text-red-600 hover:text-red-900">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create Sidebar */}
|
||||||
|
{isCreateSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Create Replication Controller</h3>
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-blue-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Instructions:</p>
|
||||||
|
<p>Paste your replication controller YAML. It will be base64-encoded and sent to the API.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<textarea value={createManifest} onChange={(e) => setCreateManifest(e.target.value)} placeholder="Paste your replication controller manifest here (YAML format)..." className="w-full h-full p-4 text-xs font-mono text-green-400 bg-gray-900 border-none resize-none focus:outline-none placeholder-gray-500" style={{ fontFamily: 'Monaco, Menlo, \"Ubuntu Mono\", monospace' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={createReplicationController} disabled={isCreatingRef.current || !createManifest.trim()} className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">Create Replication Controller</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manifest Sidebar */}
|
||||||
|
{isSidebarOpen && selectedForManifest && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Replication Controller Manifest</h3>
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedForManifest?.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><div className="text-white">Loading manifest...</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">{manifest}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scale Modal */}
|
||||||
|
{isScaleModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Scale Replication Controller</h3>
|
||||||
|
<button onClick={closeScaleModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedForAction.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedForAction.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Replicas</label>
|
||||||
|
<input type="number" min="0" value={scaleReplicas} onChange={(e) => setScaleReplicas(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeScaleModal} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitScale} disabled={isScaling || !scaleReplicas.trim()} className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isScaling ? 'Scaling...' : 'Apply'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Migrate Modal */}
|
||||||
|
{isMigrateModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Migrate to Deployment</h3>
|
||||||
|
<button onClick={closeMigrateModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Trigger migration for <span className="font-medium">{selectedForAction.Name}</span> in <span className="font-medium">{selectedForAction.Namespace}</span> to a Deployment?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeMigrateModal} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitMigrate} disabled={isMigrating} className="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isMigrating ? 'Migrating...' : 'Confirm'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{isDeleteModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Delete Replication Controller</h3>
|
||||||
|
<button onClick={closeDeleteModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Are you sure you want to delete <span className="font-medium">{selectedForAction.Name}</span> in namespace <span className="font-medium">{selectedForAction.Namespace}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeDeleteModal} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitDelete} disabled={isDeleting} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isDeleting ? 'Deleting...' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,129 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
export default function Resources() {
|
export default function Resources() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
|
const [mValue, setMValue] = useState<string>('5')
|
||||||
|
const [runValue, setRunValue] = useState<string>('1')
|
||||||
|
const [dValue, setDValue] = useState<string>('')
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
|
const [error, setError] = useState<string>('')
|
||||||
|
const [result, setResult] = useState<string>('')
|
||||||
|
type Usage = { Used: number, Total: number, Unit: string }
|
||||||
|
type ResourceUsage = { CPU: Usage, Memory: Usage, Storage: Usage, Network: Usage }
|
||||||
|
const [usage, setUsage] = useState<ResourceUsage | null>(null)
|
||||||
|
|
||||||
|
const fetchResourceUsage = async () => {
|
||||||
|
setError('')
|
||||||
|
setResult('')
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (clusterName) params.append('Name', clusterName)
|
||||||
|
if (mValue) params.append('m', mValue)
|
||||||
|
if (runValue) params.append('run', runValue)
|
||||||
|
if (dValue) params.append('d', dValue)
|
||||||
|
const url = `http://localhost:8082/cluster_resource_usage?${params.toString()}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
if (data == null) {
|
||||||
|
const text = await response.text()
|
||||||
|
setResult(text)
|
||||||
|
} else {
|
||||||
|
setResult(typeof data === 'string' ? data : JSON.stringify(data, null, 2))
|
||||||
|
if (data && data.CPU && data.Memory && data.Storage && data.Network) {
|
||||||
|
setUsage(data as ResourceUsage)
|
||||||
|
} else {
|
||||||
|
setUsage(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
const data = await response.json().catch(() => ({} as any))
|
||||||
|
setError((data && (data.message || data.error)) || 'Failed to fetch resource usage')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(String(err))
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('selectedCluster')
|
||||||
|
if (stored) setClusterName(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl font-semibold">Resources</h1>
|
<div>
|
||||||
<p className="text-sm text-gray-600">View and manage Kubernetes resources.</p>
|
<h1 className="text-2xl font-semibold">Resource Usage</h1>
|
||||||
<div className="mt-4 bg-white border border-gray-200 rounded-lg p-4 text-sm text-gray-700">
|
<p className="text-sm text-gray-600">Query cluster resource usage metrics.</p>
|
||||||
Coming soon.
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">m</label>
|
||||||
|
<input value={mValue} onChange={(e) => setMValue(e.target.value)} placeholder="minutes" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">run</label>
|
||||||
|
<input value={runValue} onChange={(e) => setRunValue(e.target.value)} placeholder="run" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">d</label>
|
||||||
|
<input value={dValue} onChange={(e) => setDValue(e.target.value)} placeholder="dimension" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<button onClick={fetchResourceUsage} disabled={isLoading} className="ml-auto px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">
|
||||||
|
{isLoading ? 'Fetching...' : 'Fetch Usage'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="mt-3 text-sm text-red-600">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usage && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{([
|
||||||
|
{ key: 'CPU', label: 'CPU' },
|
||||||
|
{ key: 'Memory', label: 'Memory' },
|
||||||
|
{ key: 'Storage', label: 'Storage' },
|
||||||
|
{ key: 'Network', label: 'Network' }
|
||||||
|
] as const).map((item) => {
|
||||||
|
const u = usage[item.key]
|
||||||
|
const pct = u.Total > 0 ? Math.min(100, Math.round((u.Used / u.Total) * 100)) : 0
|
||||||
|
return (
|
||||||
|
<div key={item.key} className="border border-gray-200 rounded-md p-3">
|
||||||
|
<div className="text-sm text-gray-600">{item.label}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-gray-900">{u.Used} / {u.Total} {u.Unit}</div>
|
||||||
|
<div className="mt-2 h-2 bg-gray-200 rounded">
|
||||||
|
<div className="h-2 bg-blue-600 rounded" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">{pct}% used</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="text-sm text-gray-700 mb-2">Response</div>
|
||||||
|
<div className="min-h-[200px] bg-gray-900 overflow-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">{result || (isLoading ? 'Loading...' : 'No data')}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,46 +1,235 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
export default function Secrets() {
|
export default function Secrets() {
|
||||||
const secrets = [
|
const navigate = useNavigate()
|
||||||
{
|
interface SecretItem {
|
||||||
name: 'db-credentials',
|
Name: string
|
||||||
namespace: 'default',
|
Namespace: string
|
||||||
type: 'Opaque',
|
Type: string
|
||||||
data: 2,
|
Data: string | number
|
||||||
age: '2h'
|
Age: string
|
||||||
},
|
}
|
||||||
{
|
|
||||||
name: 'tls-secret',
|
interface Namespace {
|
||||||
namespace: 'default',
|
Name: string
|
||||||
type: 'kubernetes.io/tls',
|
Status: string
|
||||||
data: 2,
|
Age: string
|
||||||
age: '1d'
|
}
|
||||||
},
|
|
||||||
{
|
const [secrets, setSecrets] = useState<SecretItem[]>([])
|
||||||
name: 'docker-registry',
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
namespace: 'default',
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
type: 'kubernetes.io/dockerconfigjson',
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
data: 1,
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
age: '3h'
|
// View sidebar
|
||||||
},
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
]
|
const [selectedForManifest, setSelectedForManifest] = useState<SecretItem | null>(null)
|
||||||
|
const [manifest, setManifest] = useState<string>('')
|
||||||
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
|
// Create sidebar
|
||||||
|
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [createManifest, setCreateManifest] = useState<string>('')
|
||||||
|
const isCreatingRef = useRef<boolean>(false)
|
||||||
|
// Delete modal
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||||
|
const isDeletingRef = useRef<boolean>(false)
|
||||||
|
const [selectedForDelete, setSelectedForDelete] = useState<SecretItem | null>(null)
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setNamespaces(data || [])
|
||||||
|
if (data && data.length > 0 && !selectedNamespace) setSelectedNamespace(data[0].Name)
|
||||||
|
} else if (response.status === 401) { navigate('/login') }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch namespaces', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSecrets = async (namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_secret?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_secret?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setSecrets(data || [])
|
||||||
|
} else if (response.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch secrets')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch secrets', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (ns: string) => {
|
||||||
|
setSelectedNamespace(ns)
|
||||||
|
fetchSecrets(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
|
try { return btoa(unescape(encodeURIComponent(input))) } catch (_err) {
|
||||||
|
const bytes = new TextEncoder().encode(input)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View
|
||||||
|
const handleViewClick = async (s: SecretItem) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
if (isLoadingManifestRef.current) return
|
||||||
|
isLoadingManifestRef.current = true
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
setSelectedForManifest(s)
|
||||||
|
setIsLoadingManifest(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8082/secret_manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Namespace: s.Namespace, Secretname: s.Name })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setManifest(typeof data === 'string' ? data.replace(/\\n/g, '\n') : JSON.stringify(data, null, 2))
|
||||||
|
} else if (res.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await res.json()
|
||||||
|
setManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch secret manifest', err)
|
||||||
|
setManifest('Failed to fetch manifest: ' + err)
|
||||||
|
} finally {
|
||||||
|
isLoadingManifestRef.current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create
|
||||||
|
const openCreate = () => { setIsCreateSidebarOpen(true); setCreateManifest('') }
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (!createManifest.trim() || !clusterName) { alert('Please enter a secret manifest'); return }
|
||||||
|
if (isCreatingRef.current) return
|
||||||
|
isCreatingRef.current = true
|
||||||
|
try {
|
||||||
|
const encoded = encodeBase64Utf8(createManifest)
|
||||||
|
const response = await fetch('http://localhost:8082/secret_create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Manifest: encoded })
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsCreateSidebarOpen(false)
|
||||||
|
setCreateManifest('')
|
||||||
|
await fetchSecrets(selectedNamespace)
|
||||||
|
alert('Secret created successfully!')
|
||||||
|
} else if (response.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to create secret: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create secret', err)
|
||||||
|
alert('Failed to create secret: ' + err)
|
||||||
|
} finally {
|
||||||
|
isCreatingRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const openDelete = (s: SecretItem) => { setSelectedForDelete(s); setIsDeleteModalOpen(true) }
|
||||||
|
const closeDelete = () => { setIsDeleteModalOpen(false); setSelectedForDelete(null) }
|
||||||
|
const submitDelete = async () => {
|
||||||
|
if (!selectedForDelete || !clusterName) return
|
||||||
|
if (isDeletingRef.current) return
|
||||||
|
isDeletingRef.current = true
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/secret_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(selectedForDelete.Namespace)}&secretName=${encodeURIComponent(selectedForDelete.Name)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
closeDelete()
|
||||||
|
await fetchSecrets(selectedNamespace)
|
||||||
|
alert('Secret deleted successfully')
|
||||||
|
} else if (response.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to delete secret: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete secret', err)
|
||||||
|
alert('Failed to delete secret: ' + err)
|
||||||
|
} finally {
|
||||||
|
isDeletingRef.current = false
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('selectedCluster')
|
||||||
|
if (stored) setClusterName(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { if (clusterName) fetchNamespaces() }, [clusterName])
|
||||||
|
useEffect(() => { if (selectedNamespace && clusterName) fetchSecrets(selectedNamespace) }, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen ? 'mr-96' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Secrets</h1>
|
<h1 className="text-2xl font-semibold">Secrets</h1>
|
||||||
<p className="text-sm text-gray-600">Manage sensitive configuration data and credentials.</p>
|
<p className="text-sm text-gray-600">Manage sensitive configuration data and credentials.</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-lg font-medium">Secret List</h2>
|
<h2 className="text-lg font-medium">Secret List</h2>
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
Create Secret
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">Namespace:</label>
|
||||||
</button>
|
<select id="namespace-select" value={selectedNamespace} onChange={(e) => handleNamespaceChange(e.target.value)} className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>{ns.Name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={openCreate} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">Create Secret</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading secrets...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -53,30 +242,118 @@ export default function Secrets() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{secrets.map((secret) => (
|
{secrets.map((s) => (
|
||||||
<tr key={secret.name} className="hover:bg-gray-50">
|
<tr key={s.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{secret.name}</div>
|
<div className="text-sm font-medium text-gray-900">{s.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{secret.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{s.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
{secret.type}
|
{s.Type}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{secret.data}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{s.Data}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{secret.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{s.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button onClick={() => handleViewClick(s)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
||||||
<button className="text-green-600 hover:text-green-900 mr-3">Edit</button>
|
<button onClick={() => openDelete(s)} className="text-red-600 hover:text-red-900">Delete</button>
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* View Manifest Sidebar */}
|
||||||
|
{isSidebarOpen && selectedForManifest && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Secret Manifest</h3>
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedForManifest?.Namespace}</div>
|
||||||
|
<div><span className="font-medium">Type:</span> {selectedForManifest?.Type}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><div className="text-white">Loading manifest...</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">{manifest}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Secret Sidebar */}
|
||||||
|
{isCreateSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Create Secret</h3>
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-blue-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Instructions:</p>
|
||||||
|
<p>Paste your Secret YAML. It will be base64-encoded and sent to the API.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<textarea value={createManifest} onChange={(e) => setCreateManifest(e.target.value)} placeholder="Paste your secret manifest here (YAML format)..." className="w-full h-full p-4 text-xs font-mono text-green-400 bg-gray-900 border-none resize-none focus:outline-none placeholder-gray-500" style={{ fontFamily: 'Monaco, Menlo, \"Ubuntu Mono\", monospace' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitCreate} disabled={isCreatingRef.current || !createManifest.trim()} className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">Create Secret</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{isDeleteModalOpen && selectedForDelete && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Delete Secret</h3>
|
||||||
|
<button onClick={closeDelete} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Are you sure you want to delete <span className="font-medium">{selectedForDelete.Name}</span> in namespace <span className="font-medium">{selectedForDelete.Namespace}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeDelete} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitDelete} disabled={isDeleting} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isDeleting ? 'Deleting...' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,55 +1,238 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
export default function Services() {
|
export default function Services() {
|
||||||
const services = [
|
const navigate = useNavigate()
|
||||||
{
|
interface ServiceItem {
|
||||||
name: 'nginx-service',
|
Name: string
|
||||||
namespace: 'default',
|
Namespace: string
|
||||||
type: 'ClusterIP',
|
Type: string
|
||||||
clusterIP: '10.96.1.10',
|
ClusterIP: string
|
||||||
externalIP: '<none>',
|
ExternalIP: string
|
||||||
ports: '80:80/TCP',
|
Ports: string
|
||||||
age: '2h',
|
Age: string
|
||||||
selector: 'app=nginx'
|
Selector: string
|
||||||
},
|
}
|
||||||
{
|
|
||||||
name: 'redis-service',
|
const handleViewClick = async (svc: ServiceItem) => {
|
||||||
namespace: 'default',
|
if (!clusterName) return
|
||||||
type: 'ClusterIP',
|
if (isLoadingManifestRef.current) return
|
||||||
clusterIP: '10.96.1.11',
|
isLoadingManifestRef.current = true
|
||||||
externalIP: '<none>',
|
setIsSidebarOpen(true)
|
||||||
ports: '6379:6379/TCP',
|
setSelectedForManifest(svc)
|
||||||
age: '1d',
|
setIsLoadingManifest(true)
|
||||||
selector: 'app=redis'
|
try {
|
||||||
},
|
const res = await fetch('http://localhost:8082/service_manifest', {
|
||||||
{
|
method: 'POST',
|
||||||
name: 'app-service',
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
namespace: 'default',
|
body: JSON.stringify({ Clustername: clusterName, Namespace: svc.Namespace, Servicename: svc.Name })
|
||||||
type: 'LoadBalancer',
|
})
|
||||||
clusterIP: '10.96.1.12',
|
if (res.ok) {
|
||||||
externalIP: '192.168.1.100',
|
const data = await res.json()
|
||||||
ports: '8080:80/TCP',
|
setManifest(typeof data === 'string' ? data.replace(/\\n/g, '\n') : JSON.stringify(data, null, 2))
|
||||||
age: '4h',
|
} else if (res.status === 401) { navigate('/login') } else {
|
||||||
selector: 'app=web'
|
const data = await res.json()
|
||||||
},
|
setManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
]
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch service manifest', err)
|
||||||
|
setManifest('Failed to fetch manifest: ' + err)
|
||||||
|
} finally {
|
||||||
|
isLoadingManifestRef.current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const [services, setServices] = useState<ServiceItem[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
|
||||||
|
const [serviceForAction, setServiceForAction] = useState<ServiceItem | null>(null)
|
||||||
|
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||||
|
const isDeletingRef = useRef<boolean>(false)
|
||||||
|
// Manifest sidebar state
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [selectedForManifest, setSelectedForManifest] = useState<ServiceItem | null>(null)
|
||||||
|
const [manifest, setManifest] = useState<string>('')
|
||||||
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
|
// Create sidebar state
|
||||||
|
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [createManifest, setCreateManifest] = useState<string>('')
|
||||||
|
const isCreatingRef = useRef<boolean>(false)
|
||||||
|
// Edit sidebar state (removed)
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setNamespaces(data || [])
|
||||||
|
if (data && data.length > 0 && !selectedNamespace) setSelectedNamespace(data[0].Name)
|
||||||
|
} else if (response.status === 401) { navigate('/login') }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch namespaces', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchServices = async (namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_services?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_services?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setServices(data || [])
|
||||||
|
} else if (response.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch services')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch services', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (ns: string) => {
|
||||||
|
setSelectedNamespace(ns)
|
||||||
|
fetchServices(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => { setIsCreateSidebarOpen(true); setCreateManifest('') }
|
||||||
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
|
try { return btoa(unescape(encodeURIComponent(input))) } catch (_err) {
|
||||||
|
const bytes = new TextEncoder().encode(input)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (!createManifest.trim() || !clusterName) { alert('Please enter a service manifest'); return }
|
||||||
|
if (isCreatingRef.current) return
|
||||||
|
isCreatingRef.current = true
|
||||||
|
try {
|
||||||
|
const encoded = encodeBase64Utf8(createManifest)
|
||||||
|
const response = await fetch('http://localhost:8082/service_create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Manifest: encoded })
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsCreateSidebarOpen(false)
|
||||||
|
setCreateManifest('')
|
||||||
|
await fetchServices(selectedNamespace)
|
||||||
|
alert('Service created successfully!')
|
||||||
|
} else if (response.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to create service: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create service', err)
|
||||||
|
alert('Failed to create service: ' + err)
|
||||||
|
} finally {
|
||||||
|
isCreatingRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit feature removed
|
||||||
|
|
||||||
|
const openDeleteModal = (svc: ServiceItem) => { setServiceForAction(svc); setIsDeleteModalOpen(true) }
|
||||||
|
const closeDeleteModal = () => { setIsDeleteModalOpen(false); setServiceForAction(null) }
|
||||||
|
const submitDelete = async () => {
|
||||||
|
if (!serviceForAction || !clusterName) return
|
||||||
|
if (isDeletingRef.current) return
|
||||||
|
isDeletingRef.current = true
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/service_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(serviceForAction.Namespace)}&serviceName=${encodeURIComponent(serviceForAction.Name)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
closeDeleteModal()
|
||||||
|
await fetchServices(selectedNamespace)
|
||||||
|
alert('Service deleted successfully')
|
||||||
|
} else if (response.status === 401) { navigate('/login') } else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to delete service: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete service', err)
|
||||||
|
alert('Failed to delete service: ' + err)
|
||||||
|
} finally {
|
||||||
|
isDeletingRef.current = false
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('selectedCluster')
|
||||||
|
if (stored) setClusterName(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { if (clusterName) fetchNamespaces() }, [clusterName])
|
||||||
|
useEffect(() => { if (selectedNamespace && clusterName) fetchServices(selectedNamespace) }, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Services</h1>
|
<h1 className="text-2xl font-semibold">Services</h1>
|
||||||
<p className="text-sm text-gray-600">Manage network services and load balancing.</p>
|
<p className="text-sm text-gray-600">Manage network services and load balancing.</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Service Sidebar removed */}
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-lg font-medium">Service List</h2>
|
<h2 className="text-lg font-medium">Service List</h2>
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
Create Service
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">Namespace:</label>
|
||||||
</button>
|
<select id="namespace-select" value={selectedNamespace} onChange={(e) => handleNamespaceChange(e.target.value)} className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>{ns.Name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={openCreate} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">Create Service</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading services...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -66,32 +249,120 @@ export default function Services() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{services.map((service) => (
|
{services.map((service) => (
|
||||||
<tr key={service.name} className="hover:bg-gray-50">
|
<tr key={service.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{service.name}</div>
|
<div className="text-sm font-medium text-gray-900">{service.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{service.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{service.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
{service.type}
|
{service.Type}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">{service.clusterIP}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">{service.ClusterIP}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{service.externalIP}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{service.ExternalIP}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{service.ports}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{service.Ports}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{service.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{service.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{service.selector}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{service.Selector}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button onClick={() => handleViewClick(service)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
||||||
<button className="text-green-600 hover:text-green-900 mr-3">Edit</button>
|
<button onClick={() => openDeleteModal(service)} className="text-red-600 hover:text-red-900">Delete</button>
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Service Manifest Sidebar */}
|
||||||
|
{isSidebarOpen && selectedForManifest && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Service Manifest</h3>
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedForManifest?.Namespace}</div>
|
||||||
|
<div><span className="font-medium">Type:</span> {selectedForManifest?.Type}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><div className="text-white">Loading manifest...</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">{manifest}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Service Sidebar */}
|
||||||
|
{isCreateSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Create Service</h3>
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-blue-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Instructions:</p>
|
||||||
|
<p>Paste your Service YAML. It will be base64-encoded and sent to the API.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<textarea value={createManifest} onChange={(e) => setCreateManifest(e.target.value)} placeholder="Paste your service manifest here (YAML format)..." className="w-full h-full p-4 text-xs font-mono text-green-400 bg-gray-900 border-none resize-none focus:outline-none placeholder-gray-500" style={{ fontFamily: 'Monaco, Menlo, \"Ubuntu Mono\", monospace' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitCreate} disabled={isCreatingRef.current || !createManifest.trim()} className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">Create Service</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{isDeleteModalOpen && serviceForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Delete Service</h3>
|
||||||
|
<button onClick={closeDeleteModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Are you sure you want to delete service <span className="font-medium">{serviceForAction.Name}</span> in namespace <span className="font-medium">{serviceForAction.Namespace}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={closeDeleteModal} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitDelete} disabled={isDeleting} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isDeleting ? 'Deleting...' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,96 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTheme } from '../hooks/useTheme'
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
const [apiBase, setApiBase] = useState<string>('http://localhost:8082')
|
||||||
|
const [token, setToken] = useState<string>('')
|
||||||
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
|
const [persisted, setPersisted] = useState<boolean>(false)
|
||||||
|
const { theme, changeTheme } = useTheme()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedApi = localStorage.getItem('settings:apiBase')
|
||||||
|
const storedToken = localStorage.getItem('auth:token')
|
||||||
|
const storedCluster = localStorage.getItem('selectedCluster')
|
||||||
|
if (storedApi) setApiBase(storedApi)
|
||||||
|
if (storedToken) setToken(storedToken)
|
||||||
|
if (storedCluster) setClusterName(storedCluster)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveSettings = () => {
|
||||||
|
localStorage.setItem('settings:apiBase', apiBase)
|
||||||
|
localStorage.setItem('auth:token', token)
|
||||||
|
if (clusterName) localStorage.setItem('selectedCluster', clusterName)
|
||||||
|
setPersisted(true)
|
||||||
|
setTimeout(() => setPersisted(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAuth = () => {
|
||||||
|
localStorage.removeItem('auth:user')
|
||||||
|
localStorage.removeItem('auth:token')
|
||||||
|
setToken('')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||||
<p className="text-sm text-gray-600">Project-wide preferences and configuration.</p>
|
<p className="text-sm text-gray-600">Project-wide preferences and configuration.</p>
|
||||||
<div className="mt-4 bg-white border border-gray-200 rounded-lg p-4 text-sm text-gray-700">
|
</div>
|
||||||
Coming soon.
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-medium">API & Authentication</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">API Base URL</label>
|
||||||
|
<input value={apiBase} onChange={(e) => setApiBase(e.target.value)} placeholder="http://localhost:8082" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Future requests can read from this value if you wire it in fetch helpers.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Auth Token</label>
|
||||||
|
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="Paste JWT token" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button onClick={clearAuth} className="px-3 py-1.5 text-sm rounded-md border border-gray-300 hover:bg-gray-50">Clear Token</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Default Cluster</label>
|
||||||
|
<input value={clusterName} onChange={(e) => setClusterName(e.target.value)} placeholder="cluster name" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Used by pages as `selectedCluster`.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button onClick={saveSettings} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">Save</button>
|
||||||
|
</div>
|
||||||
|
{persisted && <div className="text-sm text-green-600">Saved.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-medium">Appearance</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Theme</label>
|
||||||
|
<select value={theme} onChange={(e) => changeTheme(e.target.value as 'light' | 'dark')} className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">Theme is saved in cookies and applied immediately. Changes take effect across all pages.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-medium">Danger Zone</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-3">
|
||||||
|
<button onClick={() => { localStorage.clear(); setToken(''); setClusterName(''); setApiBase('http://localhost:8082'); }} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm">Clear All Local Storage</button>
|
||||||
|
<p className="text-xs text-gray-500">This will remove saved token, cluster selection, and settings.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,55 +1,422 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
interface StatefulSet {
|
||||||
|
Name: string
|
||||||
|
Namespace: string
|
||||||
|
Ready: string
|
||||||
|
Current: string
|
||||||
|
Updated: string
|
||||||
|
Age: string
|
||||||
|
Image: string
|
||||||
|
ServiceName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Namespace {
|
||||||
|
Name: string
|
||||||
|
Status: string
|
||||||
|
Age: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function StatefulSets() {
|
export default function StatefulSets() {
|
||||||
const statefulSets = [
|
const [statefulSets, setStatefulSets] = useState<StatefulSet[]>([])
|
||||||
{
|
const [isLoadingStatefulSets, setIsLoadingStatefulSets] = useState<boolean>(false)
|
||||||
name: 'redis-master',
|
const [clusterName, setClusterName] = useState<string>('')
|
||||||
namespace: 'default',
|
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||||||
ready: '1/1',
|
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||||||
current: 1,
|
|
||||||
updated: 1,
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
|
||||||
age: '1d',
|
const [selectedForManifest, setSelectedForManifest] = useState<StatefulSet | null>(null)
|
||||||
image: 'redis:6.2',
|
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
|
||||||
serviceName: 'redis-master'
|
const [manifest, setManifest] = useState<string>('')
|
||||||
|
const isLoadingManifestRef = useRef<boolean>(false)
|
||||||
|
|
||||||
|
const [isScaleModalOpen, setIsScaleModalOpen] = useState<boolean>(false)
|
||||||
|
const [scaleReplicas, setScaleReplicas] = useState<string>('')
|
||||||
|
const [isScaling, setIsScaling] = useState<boolean>(false)
|
||||||
|
const isScalingRef = useRef<boolean>(false)
|
||||||
|
|
||||||
|
const [isRolloutModalOpen, setIsRolloutModalOpen] = useState<boolean>(false)
|
||||||
|
const [isRolling, setIsRolling] = useState<boolean>(false)
|
||||||
|
const isRollingRef = useRef<boolean>(false)
|
||||||
|
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||||
|
const isDeletingRef = useRef<boolean>(false)
|
||||||
|
const [selectedForAction, setSelectedForAction] = useState<StatefulSet | null>(null)
|
||||||
|
|
||||||
|
// Create StatefulSet
|
||||||
|
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
|
||||||
|
const [createManifest, setCreateManifest] = useState<string>('')
|
||||||
|
const [isCreating, setIsCreating] = useState<boolean>(false)
|
||||||
|
const isCreatingRef = useRef<boolean>(false)
|
||||||
|
|
||||||
|
// Encode a string to Base64 with UTF-8 safety
|
||||||
|
const encodeBase64Utf8 = (input: string): string => {
|
||||||
|
try {
|
||||||
|
return btoa(unescape(encodeURIComponent(input)))
|
||||||
|
} catch (_err) {
|
||||||
|
const bytes = new TextEncoder().encode(input)
|
||||||
|
let binary = ''
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk))
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStatefulSets = async (namespace?: string) => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
setIsLoadingStatefulSets(true)
|
||||||
|
const url = namespace
|
||||||
|
? `http://localhost:8082/cluster_statefulset?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}`
|
||||||
|
: `http://localhost:8082/cluster_statefulset?Name=${encodeURIComponent(clusterName)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setStatefulSets(data || [])
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch statefulsets')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch statefulsets', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingStatefulSets(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
if (!clusterName) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setNamespaces(data || [])
|
||||||
|
if (data && data.length > 0 && !selectedNamespace) {
|
||||||
|
setSelectedNamespace(data[0].Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
console.error(data.message || 'Failed to fetch namespaces')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch namespaces', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchManifest = async (sts: StatefulSet) => {
|
||||||
|
if (isLoadingManifestRef.current) return
|
||||||
|
isLoadingManifestRef.current = true
|
||||||
|
setIsLoadingManifest(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/statefulset_manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
{
|
body: JSON.stringify({
|
||||||
name: 'postgres',
|
Clustername: clusterName,
|
||||||
namespace: 'default',
|
Namespace: sts.Namespace,
|
||||||
ready: '1/1',
|
Statefulset: sts.Name
|
||||||
current: 1,
|
})
|
||||||
updated: 1,
|
})
|
||||||
age: '3h',
|
if (response.ok) {
|
||||||
image: 'postgres:13',
|
const data = await response.json()
|
||||||
serviceName: 'postgres'
|
if (typeof data === 'string') {
|
||||||
|
setManifest(data.replace(/\\n/g, '\n'))
|
||||||
|
} else {
|
||||||
|
setManifest(JSON.stringify(data, null, 2))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
setManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch statefulset manifest', err)
|
||||||
|
setManifest('Failed to fetch manifest: ' + err)
|
||||||
|
} finally {
|
||||||
|
isLoadingManifestRef.current = false
|
||||||
|
setIsLoadingManifest(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNamespaceChange = (ns: string) => {
|
||||||
|
setSelectedNamespace(ns)
|
||||||
|
fetchStatefulSets(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewClick = (sts: StatefulSet) => {
|
||||||
|
setSelectedForManifest(sts)
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
fetchManifest(sts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openScaleModal = (sts: StatefulSet) => {
|
||||||
|
setSelectedForAction(sts)
|
||||||
|
const ready = sts.Ready != null ? String(sts.Ready) : ''
|
||||||
|
const parts = ready.includes('/') ? ready.split('/') : []
|
||||||
|
const desired = parts.length === 2 ? parts[1] : ready
|
||||||
|
const match = desired ? desired.match(/\d+/) : null
|
||||||
|
setScaleReplicas(match ? match[0] : '')
|
||||||
|
setIsScaleModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitScale = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (isScalingRef.current) return
|
||||||
|
const replicasNum = parseInt(scaleReplicas, 10)
|
||||||
|
if (Number.isNaN(replicasNum) || replicasNum < 0) {
|
||||||
|
alert('Please enter a valid replicas number')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isScalingRef.current = true
|
||||||
|
setIsScaling(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/statefulset_scale', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
{
|
body: JSON.stringify({
|
||||||
name: 'elasticsearch',
|
Clustername: clusterName,
|
||||||
namespace: 'monitoring',
|
Namespace: selectedForAction.Namespace,
|
||||||
ready: '3/3',
|
Statefulset: selectedForAction.Name,
|
||||||
current: 3,
|
Replicas: replicasNum
|
||||||
updated: 3,
|
})
|
||||||
age: '2d',
|
})
|
||||||
image: 'elasticsearch:7.17',
|
if (response.ok) {
|
||||||
serviceName: 'elasticsearch'
|
setIsScaleModalOpen(false)
|
||||||
|
await fetchStatefulSets(selectedNamespace)
|
||||||
|
alert('Scale request submitted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to scale statefulset: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to scale statefulset', err)
|
||||||
|
alert('Failed to scale statefulset: ' + err)
|
||||||
|
} finally {
|
||||||
|
isScalingRef.current = false
|
||||||
|
setIsScaling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openRolloutModal = (sts: StatefulSet) => {
|
||||||
|
setSelectedForAction(sts)
|
||||||
|
setIsRolloutModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitRolloutRestart = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (isRollingRef.current) return
|
||||||
|
isRollingRef.current = true
|
||||||
|
setIsRolling(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8082/statefulset_rollout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
},
|
},
|
||||||
]
|
body: JSON.stringify({
|
||||||
|
Clustername: clusterName,
|
||||||
|
Namespace: selectedForAction.Namespace,
|
||||||
|
Statefulset: selectedForAction.Name,
|
||||||
|
Action: 'restart'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsRolloutModalOpen(false)
|
||||||
|
await fetchStatefulSets(selectedNamespace)
|
||||||
|
alert('Rollout restart triggered successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to rollout restart: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to rollout restart', err)
|
||||||
|
alert('Failed to rollout restart: ' + err)
|
||||||
|
} finally {
|
||||||
|
isRollingRef.current = false
|
||||||
|
setIsRolling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDeleteModal = (sts: StatefulSet) => {
|
||||||
|
setSelectedForAction(sts)
|
||||||
|
setIsDeleteModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDelete = async () => {
|
||||||
|
if (!selectedForAction || !clusterName) return
|
||||||
|
if (isDeletingRef.current) return
|
||||||
|
isDeletingRef.current = true
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const url = `http://localhost:8082/statefulSet_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(selectedForAction.Namespace)}&statefulSetName=${encodeURIComponent(selectedForAction.Name)}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `${localStorage.getItem('auth:token') || ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setIsDeleteModalOpen(false)
|
||||||
|
await fetchStatefulSets(selectedNamespace)
|
||||||
|
alert('StatefulSet deleted successfully')
|
||||||
|
} else {
|
||||||
|
const data = await response.json()
|
||||||
|
alert('Failed to delete statefulset: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete statefulset', err)
|
||||||
|
alert('Failed to delete statefulset: ' + err)
|
||||||
|
} finally {
|
||||||
|
isDeletingRef.current = false
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedClusterName = localStorage.getItem('selectedCluster')
|
||||||
|
if (storedClusterName) setClusterName(storedClusterName)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clusterName) fetchNamespaces()
|
||||||
|
}, [clusterName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNamespace && clusterName) fetchStatefulSets(selectedNamespace)
|
||||||
|
}, [selectedNamespace, clusterName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">StatefulSets</h1>
|
<h1 className="text-2xl font-semibold">StatefulSets</h1>
|
||||||
<p className="text-sm text-gray-600">Manage stateful applications with persistent storage.</p>
|
<p className="text-sm text-gray-600">Manage stateful applications with persistent storage.</p>
|
||||||
|
{clusterName && (
|
||||||
|
<p className="text-sm text-blue-600 mt-1">Cluster: <span className="font-medium">{clusterName}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create StatefulSet Sidebar */}
|
||||||
|
{isCreateSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Create StatefulSet</h3>
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-blue-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">Instructions:</p>
|
||||||
|
<p>Paste your statefulset YAML. It will be base64-encoded and sent to the API.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<textarea
|
||||||
|
value={createManifest}
|
||||||
|
onChange={(e) => setCreateManifest(e.target.value)}
|
||||||
|
placeholder="Paste your statefulset manifest here (YAML format)..."
|
||||||
|
className="w-full h-full p-4 text-xs font-mono text-green-400 bg-gray-900 border-none resize-none focus:outline-none placeholder-gray-500"
|
||||||
|
style={{ fontFamily: 'Monaco, Menlo, \"Ubuntu Mono\", monospace' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button onClick={() => setIsCreateSidebarOpen(false)} className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={async () => {
|
||||||
|
if (!createManifest.trim() || !clusterName) { alert('Please enter a manifest'); return }
|
||||||
|
if (isCreatingRef.current) return
|
||||||
|
isCreatingRef.current = true
|
||||||
|
setIsCreating(true)
|
||||||
|
try {
|
||||||
|
const encoded = encodeBase64Utf8(createManifest)
|
||||||
|
const res = await fetch('http://localhost:8082/statefulset_create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
|
||||||
|
body: JSON.stringify({ Clustername: clusterName, Manifest: encoded })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setIsCreateSidebarOpen(false)
|
||||||
|
setCreateManifest('')
|
||||||
|
await fetchStatefulSets(selectedNamespace)
|
||||||
|
alert('StatefulSet created successfully!')
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
alert('Failed to create statefulset: ' + (data.message || 'Unknown error'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create statefulset', err)
|
||||||
|
alert('Failed to create statefulset: ' + err)
|
||||||
|
} finally {
|
||||||
|
isCreatingRef.current = false
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}} disabled={isCreating || !createManifest.trim()} className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">
|
||||||
|
{isCreating ? 'Creating...' : 'Create StatefulSet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-lg font-medium">StatefulSet List</h2>
|
<h2 className="text-lg font-medium">StatefulSet List</h2>
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
|
<label htmlFor="namespace-select" className="text-sm font-medium text-gray-700">Namespace:</label>
|
||||||
|
<select
|
||||||
|
id="namespace-select"
|
||||||
|
value={selectedNamespace}
|
||||||
|
onChange={(e) => handleNamespaceChange(e.target.value)}
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Namespaces</option>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<option key={ns.Name} value={ns.Name}>{ns.Name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsCreateSidebarOpen(true); setCreateManifest('') }}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
|
||||||
|
>
|
||||||
Create StatefulSet
|
Create StatefulSet
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{isLoadingStatefulSets ? (
|
||||||
|
<div className="p-6 text-sm text-gray-600">Loading statefulsets...</div>
|
||||||
|
) : (
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -66,29 +433,140 @@ export default function StatefulSets() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{statefulSets.map((sts) => (
|
{statefulSets.map((sts) => (
|
||||||
<tr key={sts.name} className="hover:bg-gray-50">
|
<tr key={sts.Name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">{sts.name}</div>
|
<div className="text-sm font-medium text-gray-900">{sts.Name}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.namespace}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.Namespace}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.ready}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.Ready}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.current}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.Current}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.updated}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.Updated}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.age}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.Age}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.image}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.Image}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.serviceName}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{sts.ServiceName}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button onClick={() => handleViewClick(sts)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
||||||
<button className="text-green-600 hover:text-green-900 mr-3">Scale</button>
|
<button onClick={() => openScaleModal(sts)} className="text-green-600 hover:text-green-900 mr-3">Scale</button>
|
||||||
<button className="text-orange-600 hover:text-orange-900 mr-3">Rollout</button>
|
<button onClick={() => openRolloutModal(sts)} className="text-orange-600 hover:text-orange-900 mr-3">Rollout</button>
|
||||||
<button className="text-red-600 hover:text-red-900">Delete</button>
|
<button onClick={() => openDeleteModal(sts)} className="text-red-600 hover:text-red-900">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar for StatefulSet Manifest */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div className="fixed top-0 right-0 h-screen w-96 bg-white border-l border-gray-200 shadow-lg z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">StatefulSet Manifest</h3>
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedForManifest?.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedForManifest?.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-900">
|
||||||
|
{isLoadingManifest ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><div className="text-white">Loading manifest...</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-auto">
|
||||||
|
<pre className="p-4 text-xs font-mono text-green-400 whitespace-pre-wrap leading-relaxed">{manifest}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsSidebarOpen(false)} className="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scale Modal */}
|
||||||
|
{isScaleModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Scale StatefulSet</h3>
|
||||||
|
<button onClick={() => setIsScaleModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<div><span className="font-medium">Name:</span> {selectedForAction.Name}</div>
|
||||||
|
<div><span className="font-medium">Namespace:</span> {selectedForAction.Namespace}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Replicas</label>
|
||||||
|
<input type="number" min="0" value={scaleReplicas} onChange={(e) => setScaleReplicas(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={() => setIsScaleModalOpen(false)} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitScale} disabled={isScaling || !scaleReplicas.trim()} className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isScaling ? 'Scaling...' : 'Apply'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rollout Restart Modal */}
|
||||||
|
{isRolloutModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Rollout Restart</h3>
|
||||||
|
<button onClick={() => setIsRolloutModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Trigger a rollout restart for <span className="font-medium">{selectedForAction.Name}</span> in <span className="font-medium">{selectedForAction.Namespace}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={() => setIsRolloutModalOpen(false)} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitRolloutRestart} disabled={isRolling} className="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isRolling ? 'Rolling...' : 'Confirm'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{isDeleteModalOpen && selectedForAction && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-24 mx-auto p-5 border w-11/12 max-w-md shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Delete StatefulSet</h3>
|
||||||
|
<button onClick={() => setIsDeleteModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-gray-700">Are you sure you want to delete <span className="font-medium">{selectedForAction.Name}</span> in <span className="font-medium">{selectedForAction.Namespace}</span>?</div>
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 mt-4">
|
||||||
|
<button onClick={() => setIsDeleteModalOpen(false)} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm">Cancel</button>
|
||||||
|
<button onClick={submitDelete} disabled={isDeleting} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm">{isDeleting ? 'Deleting...' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user