add more feature

This commit is contained in:
behrooz
2025-09-27 17:02:34 +03:30
parent 2054ca3856
commit c17b17c23a
23 changed files with 6278 additions and 709 deletions

View File

@@ -1,8 +1,10 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { useTheme } from './hooks/useTheme'
import Login from './pages/Login'
import Register from './pages/Register'
import DashboardLayout from './layout/DashboardLayout'
import AuthGate from './middleware/AuthGate'
import Landing from './pages/Landing'
import CreateCluster from './pages/CreateCluster'
import ClusterDetail from './pages/ClusterDetail'
import Namespaces from './pages/Namespaces'
@@ -23,11 +25,14 @@ import StorageClasses from './pages/StorageClasses'
import ServiceAccounts from './pages/ServiceAccounts'
import Settings from './pages/Settings'
import Resources from './pages/Resources'
import HelmApps from './pages/HelmApps'
function App() {
// Initialize theme on app load
useTheme()
return (
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route element={<AuthGate />}>
@@ -53,6 +58,7 @@ function App() {
<Route path="serviceaccounts" element={<ServiceAccounts />} />
<Route path="settings" element={<Settings />} />
<Route path="resources" element={<Resources />} />
<Route path="helm-apps" element={<HelmApps />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/login" replace />} />

50
src/hooks/useTheme.ts Normal file
View 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 }
}

View File

@@ -4,10 +4,81 @@
html, body, #root {
height: 100%;
}
/* Light theme (default) */
body {
@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 */
.container {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;

View File

@@ -64,10 +64,10 @@ export default function DashboardLayout() {
}
>
<Box size={18} />
Namespaces (4)
Namespaces
</NavLink>
<NavLink
{/* <NavLink
to="nodes"
className={({ isActive }) =>
`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} />
Nodes (3)
</NavLink>
</NavLink> */}
<NavLink
to="pods"
@@ -88,7 +88,7 @@ export default function DashboardLayout() {
}
>
<Activity size={18} />
Pods (12)
Pods
</NavLink>
<NavLink
@@ -100,7 +100,7 @@ export default function DashboardLayout() {
}
>
<Layers size={18} />
Deployments (8)
Deployments
</NavLink>
<NavLink
@@ -112,7 +112,7 @@ export default function DashboardLayout() {
}
>
<Copy size={18} />
ReplicaSets (12)
ReplicaSets
</NavLink>
<NavLink
@@ -124,7 +124,7 @@ export default function DashboardLayout() {
}
>
<Database size={18} />
StatefulSets (3)
StatefulSets
</NavLink>
<NavLink
@@ -136,7 +136,7 @@ export default function DashboardLayout() {
}
>
<Zap size={18} />
DaemonSets (2)
DaemonSets
</NavLink>
<NavLink
@@ -148,7 +148,7 @@ export default function DashboardLayout() {
}
>
<Activity size={18} />
Jobs (5)
Jobs
</NavLink>
<NavLink
@@ -160,7 +160,7 @@ export default function DashboardLayout() {
}
>
<Clock size={18} />
CronJobs (4)
CronJobs
</NavLink>
<NavLink
@@ -172,7 +172,7 @@ export default function DashboardLayout() {
}
>
<Copy size={18} />
ReplicationControllers (1)
ReplicationControllers
</NavLink>
<NavLink
@@ -184,7 +184,7 @@ export default function DashboardLayout() {
}
>
<Network size={18} />
Services (6)
Services
</NavLink>
<NavLink
@@ -196,7 +196,7 @@ export default function DashboardLayout() {
}
>
<SettingsIcon size={18} />
ConfigMaps (15)
ConfigMaps
</NavLink>
<NavLink
@@ -208,7 +208,7 @@ export default function DashboardLayout() {
}
>
<Shield size={18} />
Secrets (9)
Secrets
</NavLink>
<NavLink
@@ -220,7 +220,7 @@ export default function DashboardLayout() {
}
>
<HardDrive size={18} />
PersistentVolumes (5)
PersistentVolumes
</NavLink>
<NavLink
@@ -232,7 +232,7 @@ export default function DashboardLayout() {
}
>
<Database size={18} />
StorageClasses (3)
StorageClasses
</NavLink>
<NavLink
@@ -244,7 +244,7 @@ export default function DashboardLayout() {
}
>
<Users size={18} />
ServiceAccounts (7)
ServiceAccounts
</NavLink>
</div>
@@ -260,6 +260,17 @@ export default function DashboardLayout() {
<Box size={18} />
Resources
</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
to="settings"
className={({ isActive }) =>
@@ -276,6 +287,8 @@ export default function DashboardLayout() {
<button
onClick={() => {
localStorage.removeItem('auth:user')
localStorage.removeItem('auth:token')
localStorage.removeItem('selectedCluster')
navigate('/login')
}}
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"

View File

@@ -1,15 +1,5 @@
import { Link, useParams } from 'react-router-dom'
import { Link, useNavigate, useParams } from 'react-router-dom'
import {
Activity,
Box,
Cpu,
Database,
HardDrive,
Layers,
Network,
Settings,
Shield,
Users,
TrendingUp,
CheckCircle,
Clock,
@@ -63,53 +53,133 @@ async function getCluster(id: string): Promise<Cluster | null> {
}
const resourceTypes = [
{ 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' },
]
// const resourceTypes = [...]; // unused placeholder removed
// Sample cluster statistics data
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'
}
}
// Sample placeholders removed; panels now use live APIs
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 [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 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(() => {
const fetchCluster = async () => {
if (params.id) {
@@ -121,6 +191,156 @@ export default function ClusterDetail() {
fetchCluster();
}, [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
if (loading) {
return <div>Loading...</div>; // You can customize the loading screen as needed
@@ -169,42 +389,34 @@ export default function ClusterDetail() {
Resource Usage
</h3>
<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">
<span className="text-gray-600">CPU</span>
<span className="font-medium">{cluster.resourceUsage.cpu.used}/{clusterStats.resourceUsage.cpu.total} {clusterStats.resourceUsage.cpu.unit}</span>
<span className="text-gray-600">{item.label}</span>
<span className="font-medium">{u.Used}/{u.Total} {u.Unit}</span>
</div>
<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>
</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 className={`${item.color} h-2 rounded-full`} style={{ width: `${pct}%` }}></div>
</div>
</div>
)
})}
</>
) : (
<div className="text-sm text-gray-600">Usage data not available.</div>
)}
</div>
</div>
@@ -215,22 +427,28 @@ export default function ClusterDetail() {
Performance Metrics
</h3>
<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-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>
<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>
<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>
<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>
</>
)}
</div>
</div>
</div>
@@ -244,22 +462,30 @@ export default function ClusterDetail() {
Cluster Health
</h3>
<div className="space-y-3">
{loadingHealth ? (
<div className="text-sm text-gray-600">Loading health...</div>
) : healthState ? (
<>
<div className="flex justify-between items-center">
<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 className="flex justify-between items-center">
<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 className="flex justify-between items-center">
<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 className="flex justify-between items-center">
<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 className="text-sm text-gray-600">Health data not available.</div>
)}
</div>
</div>
@@ -270,18 +496,24 @@ export default function ClusterDetail() {
Uptime & Maintenance
</h3>
<div className="space-y-3">
{loadingUptime ? (
<div className="text-sm text-gray-600">Loading uptime...</div>
) : (
<>
<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 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 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>
@@ -292,15 +524,11 @@ export default function ClusterDetail() {
Quick Actions
</h3>
<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">
<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">
<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">Download kubeconfig</div>
<div className="text-sm text-gray-600">Get cluster access credentials</div>
</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="text-sm text-gray-600">Check cluster and pod events</div>
</button>
@@ -330,6 +558,50 @@ export default function ClusterDetail() {
</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>
)
}

View File

@@ -1,46 +1,246 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
export default function ConfigMaps() {
const configmaps = [
{
name: 'app-config',
namespace: 'default',
data: 3,
age: '2h',
labels: 'app=web'
},
{
name: 'database-config',
namespace: 'default',
data: 5,
age: '1d',
labels: 'app=database'
},
{
name: 'redis-config',
namespace: 'default',
data: 2,
age: '3h',
labels: 'app=redis'
},
]
const navigate = useNavigate()
interface ConfigMapItem {
Name: string
Namespace: string
Data: string | number
Age: string
Labels: string
}
interface Namespace {
Name: string
Status: string
Age: string
}
const [configmaps, setConfigMaps] = useState<ConfigMapItem[]>([])
const [isLoading, setIsLoading] = useState<boolean>(false)
const [clusterName, setClusterName] = useState<string>('')
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
const [namespaces, setNamespaces] = useState<Namespace[]>([])
// 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 (
<div className="space-y-6">
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen ? 'mr-96' : ''}`}>
<div>
<h1 className="text-2xl font-semibold">ConfigMaps</h1>
<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 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 items-center justify-between">
<div className="flex items-center space-x-4">
<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">
Create ConfigMap
</button>
<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={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 className="overflow-x-auto">
{isLoading ? (
<div className="p-6 text-sm text-gray-600">Loading configmaps...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -53,26 +253,115 @@ export default function ConfigMaps() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{configmaps.map((configmap) => (
<tr key={configmap.name} className="hover:bg-gray-50">
{configmaps.map((cm) => (
<tr key={cm.Name} className="hover:bg-gray-50">
<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 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">{configmap.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">{configmap.labels}</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">{cm.Data}</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">{cm.Labels}</td>
<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-green-600 hover:text-green-900 mr-3">Edit</button>
<button className="text-red-600 hover:text-red-900">Delete</button>
<button onClick={() => handleViewClick(cm)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
<button onClick={() => openDelete(cm)} className="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}

View File

@@ -50,7 +50,7 @@ export default function CreateCluster() {
if (response.ok) {
const data = await response.json()
setClusters(data || [])
localStorage.setItem('clusters', JSON.stringify(data || []))
// localStorage.setItem('clusters', JSON.stringify(data || []))
// Check if any cluster is still progressing
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
to={`/app/clusters/${cluster.Name}`}
className="text-sm font-medium text-blue-600 hover:text-blue-900"
onClick={() => {
localStorage.setItem('selectedCluster', cluster.Name)
}}
>
{cluster.Name}
</Link>

View File

@@ -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() {
const cronJobs = [
{
name: 'backup-cronjob',
namespace: 'default',
schedule: '0 2 * * *',
suspend: false,
lastSchedule: '2h ago',
age: '1d',
image: 'backup:v1.0',
concurrencyPolicy: 'Forbid'
},
{
name: 'cleanup-cronjob',
namespace: 'default',
schedule: '0 0 * * 0',
suspend: false,
lastSchedule: '1d ago',
age: '3d',
image: 'cleanup:v1.0',
concurrencyPolicy: 'Allow'
},
{
name: 'report-cronjob',
namespace: 'default',
schedule: '0 9 * * 1-5',
suspend: false,
lastSchedule: '1h ago',
age: '2d',
image: 'reports:v1.2',
concurrencyPolicy: 'Replace'
},
{
name: 'sync-cronjob',
namespace: 'default',
schedule: '*/30 * * * *',
suspend: true,
lastSchedule: '30m ago',
age: '5d',
image: 'sync:v1.0',
concurrencyPolicy: 'Forbid'
},
]
const [cronJobs, setCronJobs] = useState<CronJobItem[]>([])
const [isLoading, setIsLoading] = useState<boolean>(false)
const [clusterName, setClusterName] = useState<string>('')
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
const [namespaces, setNamespaces] = useState<Namespace[]>([])
// View manifest
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
const [selectedForManifest, setSelectedForManifest] = useState<CronJobItem | null>(null)
const [manifest, setManifest] = useState<string>('')
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
const isLoadingManifestRef = useRef<boolean>(false)
// Trigger
const [isTriggerModalOpen, setIsTriggerModalOpen] = useState<boolean>(false)
const [isTriggering, setIsTriggering] = useState<boolean>(false)
const [selectedForAction, setSelectedForAction] = useState<CronJobItem | null>(null)
// Suspend
const [isSuspendModalOpen, setIsSuspendModalOpen] = useState<boolean>(false)
const [isSuspending, setIsSuspending] = useState<boolean>(false)
// Delete
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
const [isDeleting, setIsDeleting] = useState<boolean>(false)
// Create CronJob
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
const [createManifest, setCreateManifest] = useState<string>('')
const [isCreating, setIsCreating] = useState<boolean>(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)
}
} catch (err) {
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">CronJobs</h1>
<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 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 items-center justify-between">
<div className="flex items-center space-x-4">
<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">
Create CronJob
</button>
<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 CronJob</button>
</div>
</div>
<div className="overflow-x-auto">
{isLoading ? (
<div className="p-6 text-sm text-gray-600">Loading cronjobs...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -75,36 +321,168 @@ export default function CronJobs() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{cronJobs.map((cronJob) => (
<tr key={cronJob.name} className="hover:bg-gray-50">
{cronJobs.map((cj) => (
<tr key={cj.Name} className="hover:bg-gray-50">
<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 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 font-mono">{cronJob.schedule}</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">{cj.Schedule}</td>
<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 ${
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>
</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">{cronJob.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">{cronJob.concurrencyPolicy}</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">{cj.Age}</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">{cj.ConcurrencyPolicy}</td>
<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-green-600 hover:text-green-900 mr-3">Trigger</button>
<button className="text-orange-600 hover:text-orange-900 mr-3">Suspend</button>
<button className="text-red-600 hover:text-red-900">Delete</button>
<button onClick={() => handleViewClick(cj)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
<button onClick={() => openTriggerModal(cj)} className="text-green-600 hover:text-green-900 mr-3">Trigger</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 onClick={() => openDeleteModal(cj)} className="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}

View File

@@ -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() {
const daemonSets = [
{
name: 'fluentd-elasticsearch',
namespace: 'kube-system',
desired: 3,
current: 3,
ready: 3,
upToDate: 3,
available: 3,
age: '2d',
image: 'fluentd:v1.14'
const [daemonSets, setDaemonSets] = useState<DaemonSet[]>([])
const [isLoading, setIsLoading] = 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 [selectedForManifest, setSelectedForManifest] = useState<DaemonSet | null>(null)
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
const [manifest, setManifest] = useState<string>('')
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') || ''}`
},
{
name: 'kube-proxy',
namespace: 'kube-system',
desired: 3,
current: 3,
ready: 3,
upToDate: 3,
available: 3,
age: '2d',
image: 'kube-proxy:v1.28.0'
},
]
body: JSON.stringify({
Clustername: clusterName,
Namespace: ds.Namespace,
Daemonsetsname: ds.Name
})
})
if (response.ok) {
const data = await response.json()
setManifest(typeof data === 'string' ? data.replace(/\\n/g, '\n') : 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 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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">DaemonSets</h1>
<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 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 items-center justify-between">
<div className="flex items-center space-x-4">
<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
</button>
</div>
</div>
<div className="overflow-x-auto">
{isLoading ? (
<div className="p-6 text-sm text-gray-600">Loading daemonsets...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -59,29 +293,179 @@ export default function DaemonSets() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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">
<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 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.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.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.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.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.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.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.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 font-medium">
<button 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 className="text-red-600 hover:text-red-900">Delete</button>
<button onClick={() => handleViewClick(ds)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
<button onClick={() => openRolloutModal(ds)} className="text-orange-600 hover:text-orange-900 mr-3">Rollout</button>
<button onClick={() => openDeleteModal(ds)} className="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}

View File

@@ -1,39 +1,431 @@
import { useEffect, useRef, useState } from "react"
export default function Deployments() {
const deployments = [
{
name: 'nginx-deployment',
namespace: 'default',
ready: '3/3',
upToDate: 3,
available: 3,
age: '2h',
image: 'nginx:1.21',
strategy: 'RollingUpdate'
interface Deployments {
Name: string
Namespace: string
Available: string
Replicas: string
Message: string
Reason: string
Ready: string
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',
ready: '2/2',
upToDate: 2,
available: 2,
age: '4h',
image: 'app:v1.0',
strategy: 'RollingUpdate'
})
if (response.ok) {
const data = await response.json()
setDeployments(data || [])
} else {
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',
ready: '1/1',
upToDate: 1,
available: 1,
age: '1d',
image: 'api:v2.1',
strategy: 'Recreate'
})
if (response.ok) {
const data = await response.json()
setNamespaces(data || [])
// Set default namespace to first one if available
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 (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 (
<div className="space-y-6">
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen ? 'mr-96' : ''}`}>
<div>
<h1 className="text-2xl font-semibold">Deployments</h1>
<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="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Deployment 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-4">
<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
</button>
</div>
</div>
<div className="overflow-x-auto">
{isLoadingDeployments ? (
<div className="p-6 text-sm text-gray-600">Loading deployments...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -66,29 +484,247 @@ export default function Deployments() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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">
<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 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.upToDate}</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.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.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.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.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.Strategy}</td>
<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-green-600 hover:text-green-900 mr-3">Scale</button>
<button 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={() => handleViewClick(deployment)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
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>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}

301
src/pages/HelmApps.tsx Normal file
View 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>
)
}

View File

@@ -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() {
const jobs = [
{
name: 'backup-job',
namespace: 'default',
completions: '1/1',
duration: '5m',
age: '1h',
image: 'backup:v1.0',
status: 'Complete'
},
{
name: 'data-migration',
namespace: 'default',
completions: '3/3',
duration: '15m',
age: '2h',
image: 'migration:v2.1',
status: 'Complete'
},
{
name: 'cleanup-job',
namespace: 'default',
completions: '0/1',
duration: '2m',
age: '30m',
image: 'cleanup:v1.0',
status: 'Running'
},
{
name: 'report-generator',
namespace: 'default',
completions: '1/1',
duration: '10m',
age: '4h',
image: 'reports:v1.2',
status: 'Complete'
},
{
name: 'sync-job',
namespace: 'default',
completions: '0/1',
duration: '1m',
age: '5m',
image: 'sync:v1.0',
status: 'Failed'
},
]
const [jobs, setJobs] = useState<JobItem[]>([])
const [isLoadingJobs, setIsLoadingJobs] = useState<boolean>(false)
const [clusterName, setClusterName] = useState<string>('')
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
const [namespaces, setNamespaces] = useState<Namespace[]>([])
// View manifest sidebar
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false)
const [selectedJobForManifest, setSelectedJobForManifest] = useState<JobItem | null>(null)
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
const [jobManifest, setJobManifest] = useState<string>('')
const isLoadingManifestRef = useRef<boolean>(false)
// Logs modal
const [isLogsModalOpen, setIsLogsModalOpen] = useState<boolean>(false)
const [selectedJobForLogs, setSelectedJobForLogs] = useState<JobItem | null>(null)
const [jobLogs, setJobLogs] = useState<string>('')
const [isLoadingLogs, setIsLoadingLogs] = useState<boolean>(false)
const isLoadingLogsRef = useRef<boolean>(false)
// Delete modal
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false)
const [isDeleting, setIsDeleting] = useState<boolean>(false)
const isDeletingRef = useRef<boolean>(false)
const [selectedJobForAction, setSelectedJobForAction] = useState<JobItem | null>(null)
// Create sidebar
const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState<boolean>(false)
const [createManifest, setCreateManifest] = useState<string>('')
const [isCreating, setIsCreating] = useState<boolean>(false)
const isCreatingRef = useRef<boolean>(false)
const encodeBase64Utf8 = (input: string): string => {
try { return btoa(unescape(encodeURIComponent(input))) }
catch (_e) {
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 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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Jobs</h1>
<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 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 items-center justify-between">
<div className="flex items-center space-x-4">
<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">
Create Job
</button>
<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 Job</button>
</div>
</div>
<div className="overflow-x-auto">
{isLoadingJobs ? (
<div className="p-6 text-sm text-gray-600">Loading jobs...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -80,35 +293,152 @@ export default function Jobs() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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">
<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 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.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.image}</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.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.Image}</td>
<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 ${
job.status === 'Complete' ? 'bg-green-100 text-green-800' :
job.status === 'Running' ? 'bg-blue-100 text-blue-800' :
job.Status === 'Complete' ? 'bg-green-100 text-green-800' :
job.Status === 'Running' ? 'bg-blue-100 text-blue-800' :
'bg-red-100 text-red-800'
}`}>
{job.status}
{job.Status}
</span>
</td>
<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-orange-600 hover:text-orange-900 mr-3">Logs</button>
<button className="text-red-600 hover:text-red-900">Delete</button>
<button onClick={() => handleViewClick(job)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
<button onClick={() => handleLogsClick(job)} className="text-orange-600 hover:text-orange-900 mr-3">Logs</button>
<button onClick={() => openDeleteModal(job)} className="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}

88
src/pages/Landing.tsx Normal file
View 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>
)
}

View File

@@ -21,6 +21,7 @@ export default function Login() {
if (res.ok) {
const data = await res.json()
localStorage.setItem('auth:token', data.token)
localStorage.setItem('auth:user', JSON.stringify({ username }))
navigate('/app')
} else {
const data = await res.json()

View File

@@ -1,10 +1,47 @@
import { useEffect, useState } from "react"
interface Namespace {
Name: string
Status: string
Age: string
}
export default function Namespaces() {
const namespaces = [
{ name: 'default', status: 'Active', age: '2d', labels: 'app=web' },
{ name: 'kube-system', status: 'Active', age: '2d', labels: 'system' },
{ name: 'monitoring', status: 'Active', age: '1d', labels: 'monitoring' },
{ name: 'ingress-nginx', status: 'Active', age: '1d', labels: 'ingress' },
]
const [namespaces, setNamespace] = useState<Namespace[]>([])
// const namespaces = [
// { name: 'default', status: 'Active', age: '2d', labels: 'app=web' },
// { name: 'kube-system', status: 'Active', age: '2d', labels: 'system' },
// { 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 (
<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">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">Labels</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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">
<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 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">
{ns.status}
{ns.Status}
</span>
</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 text-gray-500">{ns.Age}</td>
<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-red-600 hover:text-red-900">Delete</button>

View File

@@ -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() {
const pods = [
{
name: 'nginx-deployment-66b6c48dd5',
namespace: 'default',
ready: '1/1',
status: 'Running',
restarts: 0,
age: '2h',
ip: '10.244.0.5',
node: 'worker-node-1',
image: 'nginx:1.21'
const [pods, setPods] = useState<Pods[]>([])
const [isLoadingPods, setIsLoadingPods] = useState<boolean>(false)
const [namespaces, setNamespaces] = useState<Namespace[]>([])
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
const [clusterName, setClusterName] = useState<string>('')
const [isLogsModalOpen, setIsLogsModalOpen] = useState<boolean>(false)
const [selectedPod, setSelectedPod] = useState<Pods | null>(null)
const [podLogs, setPodLogs] = useState<string>('')
const [isLoadingLogs, setIsLoadingLogs] = useState<boolean>(false)
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',
ready: '1/1',
status: 'Running',
restarts: 0,
age: '1d',
ip: '10.244.0.6',
node: 'worker-node-2',
image: 'redis:6.2'
})
if (response.ok) {
const data = await response.json()
setNamespaces(data || [])
// Set default namespace to first one if available
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 (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',
ready: '1/1',
status: 'Running',
restarts: 1,
age: '3h',
ip: '10.244.0.7',
node: 'worker-node-1',
image: 'postgres:13'
})
if (response.ok) {
const data = await response.json()
setPods(data || [])
} else {
const data = await response.json()
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') || ''}`
},
{
name: 'app-deployment-7d8f9c2b1a',
namespace: 'default',
ready: '2/2',
status: 'Running',
restarts: 0,
age: '4h',
ip: '10.244.0.8',
node: 'worker-node-2',
image: 'app:v1.0'
body: JSON.stringify({
Clustername: clusterName,
Namespace: pod.Namespace,
Podname: pod.Name
})
})
if (response.ok) {
const data = await response.json()
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 (
<div className="space-y-6">
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen || isCreateSidebarOpen ? 'mr-96' : ''}`}>
<div>
<h1 className="text-2xl font-semibold">Pods</h1>
<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>
{!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="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<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
</button>
</div>
</div>
<div className="overflow-x-auto">
{isLoadingPods ? (
<div className="p-6 text-sm text-gray-600">Loading pods...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -81,33 +526,266 @@ export default function Pods() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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">
<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 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.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">
<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>
</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 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.image}</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 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.Image}</td>
<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-orange-600 hover:text-orange-900 mr-3">Logs</button>
<button className="text-red-600 hover:text-red-900">Delete</button>
<button
onClick={() => handleViewClick(pod)}
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>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}

View File

@@ -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() {
const replicaSets = [
{
name: 'nginx-deployment-66b6c48dd5',
namespace: 'default',
desired: 3,
current: 3,
ready: 3,
age: '2h',
image: 'nginx:1.21',
labels: 'app=nginx'
const [replicaSets, setReplicaSets] = useState<ReplicaSet[]>([])
const [isLoadingReplicaSets, setIsLoadingReplicaSets] = 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 [selectedReplicaSetForManifest, setSelectedReplicaSetForManifest] = useState<ReplicaSet | null>(null)
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
const [replicaSetManifest, setReplicaSetManifest] = useState<string>('')
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',
namespace: 'default',
desired: 2,
current: 2,
ready: 2,
age: '4h',
image: 'app:v1.0',
labels: 'app=web'
})
if (response.ok) {
const data = await response.json()
setReplicaSets(data || [])
} else {
const data = await response.json()
console.error(data.message || 'Failed to fetch replica sets')
}
} 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',
namespace: 'default',
desired: 1,
current: 1,
ready: 1,
age: '1d',
image: 'api:v2.1',
labels: 'app=api'
})
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 (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 (
<div className="space-y-6">
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen ? 'mr-96' : ''}`}>
<div>
<h1 className="text-2xl font-semibold">ReplicaSets</h1>
<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 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 items-center justify-between">
<div className="flex items-center space-x-4">
<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">
Create ReplicaSet
</button>
<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>
<div />
</div>
</div>
<div className="overflow-x-auto">
{isLoadingReplicaSets ? (
<div className="p-6 text-sm text-gray-600">Loading replica sets...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -66,28 +322,154 @@ export default function ReplicaSets() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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">
<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 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.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.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.labels}</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.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.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.Labels}</td>
<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-green-600 hover:text-green-900 mr-3">Scale</button>
<button className="text-red-600 hover:text-red-900">Delete</button>
<button
onClick={() => handleViewClick(rs)}
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>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}

View File

@@ -1,35 +1,313 @@
import { useEffect, useRef, useState } from 'react'
export default function ReplicationControllers() {
const replicationControllers = [
{
name: 'legacy-app',
namespace: 'default',
desired: 2,
current: 2,
ready: 2,
age: '5d',
image: 'legacy-app:v1.0',
labels: 'app=legacy'
},
]
interface ReplicationControllerItem {
Name: string
Namespace: string
Desired: string | number
Current: string | number
Ready: string | number
Age: string
Image: string
Labels: string
}
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 (
<div className="space-y-6">
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen ? 'mr-96' : ''}`}>
<div>
<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>
{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 items-center justify-between">
<div className="flex items-center space-x-4">
<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">
Create Replication Controller
</button>
<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={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 className="overflow-x-auto">
{isLoading ? (
<div className="p-6 text-sm text-gray-600">Loading replication controllers...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -46,29 +324,172 @@ export default function ReplicationControllers() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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">
<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 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.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.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.labels}</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.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.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.Labels}</td>
<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-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 className="text-red-600 hover:text-red-900">Delete</button>
<button onClick={() => handleViewClick(rc)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
<button onClick={() => openScaleModal(rc)} className="text-green-600 hover:text-green-900 mr-3">Scale</button>
<button onClick={() => openMigrateModal(rc)} className="text-orange-600 hover:text-orange-900 mr-3">Migrate to Deployment</button>
<button onClick={() => openDeleteModal(rc)} className="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}

View File

@@ -1,10 +1,129 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
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 (
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Resources</h1>
<p className="text-sm text-gray-600">View and manage Kubernetes resources.</p>
<div className="mt-4 bg-white border border-gray-200 rounded-lg p-4 text-sm text-gray-700">
Coming soon.
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold">Resource Usage</h1>
<p className="text-sm text-gray-600">Query cluster resource usage metrics.</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 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>
)

View File

@@ -1,46 +1,235 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
export default function Secrets() {
const secrets = [
{
name: 'db-credentials',
namespace: 'default',
type: 'Opaque',
data: 2,
age: '2h'
},
{
name: 'tls-secret',
namespace: 'default',
type: 'kubernetes.io/tls',
data: 2,
age: '1d'
},
{
name: 'docker-registry',
namespace: 'default',
type: 'kubernetes.io/dockerconfigjson',
data: 1,
age: '3h'
},
]
const navigate = useNavigate()
interface SecretItem {
Name: string
Namespace: string
Type: string
Data: string | number
Age: string
}
interface Namespace {
Name: string
Status: string
Age: string
}
const [secrets, setSecrets] = useState<SecretItem[]>([])
const [isLoading, setIsLoading] = useState<boolean>(false)
const [clusterName, setClusterName] = useState<string>('')
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
const [namespaces, setNamespaces] = useState<Namespace[]>([])
// 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 (
<div className="space-y-6">
<div className={`space-y-6 transition-all duration-300 ${isSidebarOpen ? 'mr-96' : ''}`}>
<div>
<h1 className="text-2xl font-semibold">Secrets</h1>
<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 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 items-center justify-between">
<div className="flex items-center space-x-4">
<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">
Create Secret
</button>
<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={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 className="overflow-x-auto">
{isLoading ? (
<div className="p-6 text-sm text-gray-600">Loading secrets...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -53,30 +242,118 @@ export default function Secrets() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{secrets.map((secret) => (
<tr key={secret.name} className="hover:bg-gray-50">
{secrets.map((s) => (
<tr key={s.Name} className="hover:bg-gray-50">
<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 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">
<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>
</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">{secret.age}</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">{s.Age}</td>
<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-green-600 hover:text-green-900 mr-3">Edit</button>
<button className="text-red-600 hover:text-red-900">Delete</button>
<button onClick={() => handleViewClick(s)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
<button onClick={() => openDelete(s)} className="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}

View File

@@ -1,55 +1,238 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
export default function Services() {
const services = [
{
name: 'nginx-service',
namespace: 'default',
type: 'ClusterIP',
clusterIP: '10.96.1.10',
externalIP: '<none>',
ports: '80:80/TCP',
age: '2h',
selector: 'app=nginx'
},
{
name: 'redis-service',
namespace: 'default',
type: 'ClusterIP',
clusterIP: '10.96.1.11',
externalIP: '<none>',
ports: '6379:6379/TCP',
age: '1d',
selector: 'app=redis'
},
{
name: 'app-service',
namespace: 'default',
type: 'LoadBalancer',
clusterIP: '10.96.1.12',
externalIP: '192.168.1.100',
ports: '8080:80/TCP',
age: '4h',
selector: 'app=web'
},
]
const navigate = useNavigate()
interface ServiceItem {
Name: string
Namespace: string
Type: string
ClusterIP: string
ExternalIP: string
Ports: string
Age: string
Selector: string
}
const handleViewClick = async (svc: ServiceItem) => {
if (!clusterName) return
if (isLoadingManifestRef.current) return
isLoadingManifestRef.current = true
setIsSidebarOpen(true)
setSelectedForManifest(svc)
setIsLoadingManifest(true)
try {
const res = await fetch('http://localhost:8082/service_manifest', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` },
body: JSON.stringify({ Clustername: clusterName, Namespace: svc.Namespace, Servicename: svc.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 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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Services</h1>
<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>
{/* Edit Service Sidebar removed */}
<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 items-center justify-between">
<div className="flex items-center space-x-4">
<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">
Create Service
</button>
<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={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 className="overflow-x-auto">
{isLoading ? (
<div className="p-6 text-sm text-gray-600">Loading services...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -66,32 +249,120 @@ export default function Services() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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">
<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 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">
<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>
</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.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.selector}</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.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.Selector}</td>
<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-green-600 hover:text-green-900 mr-3">Edit</button>
<button className="text-red-600 hover:text-red-900">Delete</button>
<button onClick={() => handleViewClick(service)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
<button onClick={() => openDeleteModal(service)} className="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}

View File

@@ -1,10 +1,96 @@
import { useEffect, useState } from 'react'
import { useTheme } from '../hooks/useTheme'
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 (
<div className="space-y-2">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Settings</h1>
<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">
Coming soon.
</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">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>
)

View File

@@ -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() {
const statefulSets = [
{
name: 'redis-master',
namespace: 'default',
ready: '1/1',
current: 1,
updated: 1,
age: '1d',
image: 'redis:6.2',
serviceName: 'redis-master'
const [statefulSets, setStatefulSets] = useState<StatefulSet[]>([])
const [isLoadingStatefulSets, setIsLoadingStatefulSets] = 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 [selectedForManifest, setSelectedForManifest] = useState<StatefulSet | null>(null)
const [isLoadingManifest, setIsLoadingManifest] = useState<boolean>(false)
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') || ''}`
},
{
name: 'postgres',
namespace: 'default',
ready: '1/1',
current: 1,
updated: 1,
age: '3h',
image: 'postgres:13',
serviceName: 'postgres'
body: JSON.stringify({
Clustername: clusterName,
Namespace: sts.Namespace,
Statefulset: sts.Name
})
})
if (response.ok) {
const data = await response.json()
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') || ''}`
},
{
name: 'elasticsearch',
namespace: 'monitoring',
ready: '3/3',
current: 3,
updated: 3,
age: '2d',
image: 'elasticsearch:7.17',
serviceName: 'elasticsearch'
body: JSON.stringify({
Clustername: clusterName,
Namespace: selectedForAction.Namespace,
Statefulset: selectedForAction.Name,
Replicas: replicasNum
})
})
if (response.ok) {
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">StatefulSets</h1>
<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>
{/* 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="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<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
</button>
</div>
</div>
<div className="overflow-x-auto">
{isLoadingStatefulSets ? (
<div className="p-6 text-sm text-gray-600">Loading statefulsets...</div>
) : (
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -66,29 +433,140 @@ export default function StatefulSets() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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">
<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 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.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.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.serviceName}</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.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.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.ServiceName}</td>
<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-green-600 hover:text-green-900 mr-3">Scale</button>
<button 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={() => handleViewClick(sts)} className="text-blue-600 hover:text-blue-900 mr-3">View</button>
<button onClick={() => openScaleModal(sts)} className="text-green-600 hover:text-green-900 mr-3">Scale</button>
<button onClick={() => openRolloutModal(sts)} className="text-orange-600 hover:text-orange-900 mr-3">Rollout</button>
<button onClick={() => openDeleteModal(sts)} className="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</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>
)
}