mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 20:49:28 +00:00
Tasks 1.1, 5.1, 5.2, 5.4 and the data half of 5.3. - mechanisms.toml: closed vocabulary v1 (20 ids) every expected row cites. - vibe-py: 35 Real-Vuln-Benchmark pairs (Apache-2.0), human provenance only because the LLM-generated repositories carry no license (escalated). - vfc-js: 17 Node fix-commit pairs harvested upstream (MIT/Apache-2.0), SecBench.js itself is not vendored (no license); sinks derived on 13/17. - agent-vfc: 29 agent-trailer fixes as catalog-candidates.toml plus REVIEW.md; the loaded catalog stays empty until a human reviews them. - datasets.toml and README document every slice and its review status. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
5843 lines
316 KiB
TypeScript
5843 lines
316 KiB
TypeScript
// Provenance: amitknigam84/lensops (fixed).
|
||
// repo: amitknigam84/lensops
|
||
// commit: fe49109aedfe21dea4d24d4d9f2fa3f1ecfb6c84
|
||
// parent: 9b87ecc3a44a7fe2a5185cca1fe52441647d5748
|
||
// commit_url: https://github.com/amitknigam84/lensops/commit/fe49109aedfe21dea4d24d4d9f2fa3f1ecfb6c84
|
||
// cve:
|
||
// license: MIT
|
||
// function: setErrorMessage
|
||
// relpath: components/KubernetesManagement.tsx
|
||
// provenance: agent
|
||
// mechanism: missing_auth_guard
|
||
|
||
}: KubernetesManagementProps) {
|
||
const { token } = useAuth()
|
||
const [kubeconfig, setKubeconfig] = useState('')
|
||
|
||
// Helper to get auth headers
|
||
const getAuthHeaders = () => {
|
||
const headers: HeadersInit = { 'Content-Type': 'application/json' }
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`
|
||
}
|
||
return headers
|
||
}
|
||
const [isConnected, setIsConnected] = useState(false)
|
||
const [isConnecting, setIsConnecting] = useState(false)
|
||
const [isValidating, setIsValidating] = useState(false)
|
||
const [isResolving, setIsResolving] = useState(false)
|
||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null)
|
||
const [resolutionResult, setResolutionResult] = useState<{
|
||
resolved: boolean
|
||
fixes: string[]
|
||
remainingIssues: string[]
|
||
resolvedKubeconfig: string
|
||
hasChanges: boolean
|
||
} | null>(null)
|
||
const [deployments, setDeployments] = useState<Deployment[]>([])
|
||
const [services, setServices] = useState<Service[]>([])
|
||
const [pods, setPods] = useState<Pod[]>([])
|
||
const [nodes, setNodes] = useState<any[]>([])
|
||
const [configmaps, setConfigmaps] = useState<any[]>([])
|
||
const [secrets, setSecrets] = useState<any[]>([])
|
||
const [events, setEvents] = useState<any[]>([])
|
||
const [cronjobs, setCronjobs] = useState<CronJob[]>([])
|
||
const [jobs, setJobs] = useState<Job[]>([])
|
||
const [ingresses, setIngresses] = useState<Ingress[]>([])
|
||
const [pvcs, setPvcs] = useState<PVC[]>([])
|
||
const [namespaces, setNamespaces] = useState<Namespace[]>([])
|
||
const [selectedNamespace, setSelectedNamespace] = useState<string>('')
|
||
const [deployedNamespace, setDeployedNamespace] = useState<string | null>(null)
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||
const [selectedDeployments, setSelectedDeployments] = useState<Set<string>>(new Set())
|
||
const [imageUpdates, setImageUpdates] = useState<Map<string, ImageUpdate>>(new Map())
|
||
const [isUpdating, setIsUpdating] = useState(false)
|
||
const [updateMode, setUpdateMode] = useState<'bulk' | 'selective'>('selective')
|
||
const [showDeployments, setShowDeployments] = useState(true)
|
||
const [showServices, setShowServices] = useState(true)
|
||
const [showPods, setShowPods] = useState(true)
|
||
const [showNamespaces, setShowNamespaces] = useState(true)
|
||
const [expandedPodIssues, setExpandedPodIssues] = useState<string | null>(null)
|
||
const [expandedDeploymentIssues, setExpandedDeploymentIssues] = useState<string | null>(null)
|
||
const [activeResourceTab, setActiveResourceTab] = useState<'overview' | 'deployments' | 'pods' | 'services' | 'configmaps' | 'secrets' | 'events' | 'cronjobs' | 'jobs' | 'ingresses' | 'pvcs' | 'flow-tracing' | 'databases' | 'download-images' | 'image-change-report' | 'mongodb-wizard' | 'adminer-wizard'>('overview')
|
||
const [selectedResource, setSelectedResource] = useState<{
|
||
type: 'deployment' | 'service' | 'pod' | 'configmap' | 'secret' | 'cronjob' | 'job' | 'ingress' | 'pvc' | null
|
||
namespace: string
|
||
name: string
|
||
} | null>(null)
|
||
const [selectedEvent, setSelectedEvent] = useState<any | null>(null)
|
||
const [actionModal, setActionModal] = useState<{
|
||
type: 'scale' | 'restart' | 'delete' | null
|
||
resourceType: string
|
||
namespace: string
|
||
name: string
|
||
currentReplicas?: number
|
||
} | null>(null)
|
||
const [isActioning, setIsActioning] = useState(false)
|
||
const [scaleReplicas, setScaleReplicas] = useState<number>(1)
|
||
const [selectedLogPod, setSelectedLogPod] = useState<{
|
||
namespace: string
|
||
name: string
|
||
container?: string
|
||
} | null>(null)
|
||
const [describePod, setDescribePod] = useState<{ namespace: string; name: string } | null>(null)
|
||
const [describePodContent, setDescribePodContent] = useState<string | null>(null)
|
||
const [describePodLoading, setDescribePodLoading] = useState(false)
|
||
const [describePodError, setDescribePodError] = useState<string | null>(null)
|
||
const [showImageComparison, setShowImageComparison] = useState(false)
|
||
const [showBulkUpdateWizard, setShowBulkUpdateWizard] = useState(false)
|
||
const [bulkUpdateSelections, setBulkUpdateSelections] = useState<Set<string>>(new Set())
|
||
const [bulkUpdateImage, setBulkUpdateImage] = useState('')
|
||
const [bulkUpdateStep, setBulkUpdateStep] = useState<'select' | 'preview'>('select')
|
||
const [portForwardModal, setPortForwardModal] = useState<{ service: Service; targetPort: number; localPort: number } | null>(null)
|
||
const [portForwardCopied, setPortForwardCopied] = useState(false)
|
||
const [showCreateSecretModal, setShowCreateSecretModal] = useState(false)
|
||
const [createSecretName, setCreateSecretName] = useState('')
|
||
const [createSecretData, setCreateSecretData] = useState<{ key: string; value: string }[]>([{ key: '', value: '' }])
|
||
const [createSecretSaving, setCreateSecretSaving] = useState(false)
|
||
const [createSecretError, setCreateSecretError] = useState<string | null>(null)
|
||
const [bulkUpdateTagOnly, setBulkUpdateTagOnly] = useState(false)
|
||
// Container-wise update state
|
||
const [bulkUpdateActiveTab, setBulkUpdateActiveTab] = useState<string>('')
|
||
const [bulkUpdateContainerImages, setBulkUpdateContainerImages] = useState<Map<string, string>>(new Map())
|
||
const [bulkUpdateContainerTagOnly, setBulkUpdateContainerTagOnly] = useState<Map<string, boolean>>(new Map())
|
||
const [kubeconfigs, setKubeconfigs] = useState<Array<{
|
||
id: string
|
||
name: string
|
||
kubeconfig: string
|
||
isActive: boolean
|
||
createdAt: string
|
||
updatedAt: string
|
||
}>>([])
|
||
const [imageChangeReportDate, setImageChangeReportDate] = useState<string>(() => {
|
||
const now = new Date()
|
||
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')}`
|
||
})
|
||
const [expandedImageHistoryDeployments, setExpandedImageHistoryDeployments] = useState<Set<string>>(new Set())
|
||
const [imageChangeReportData, setImageChangeReportData] = useState<{
|
||
date: string
|
||
byDeployment: Array<{ namespace: string; deployment: string; changeCount: number; images?: string[] }>
|
||
totalChanges: number
|
||
recentInNamespace?: Array<{ date: string; deployment: string; container?: string; image?: string; oldImage?: string; source?: string; user?: string; at: string }>
|
||
currentDeployments?: Array<{
|
||
deployment: string
|
||
namespace: string
|
||
images: Array<{ container: string; image: string }>
|
||
lastUpdated?: string | null
|
||
changeCount?: number
|
||
imageFlow?: Array<{ createdAt: string; images: Array<{ container: string; image: string }>; revision?: string | number }>
|
||
}>
|
||
} | null>(null)
|
||
const [imageChangeReportLoading, setImageChangeReportLoading] = useState(false)
|
||
const [showKubeconfigManager, setShowKubeconfigManager] = useState(false)
|
||
const [newKubeconfigName, setNewKubeconfigName] = useState('')
|
||
const [newKubeconfigContent, setNewKubeconfigContent] = useState('')
|
||
const [isSavingKubeconfig, setIsSavingKubeconfig] = useState(false)
|
||
// Auto-refresh state
|
||
const [autoRefresh, setAutoRefresh] = useState(false)
|
||
const [refreshInterval, setRefreshInterval] = useState(10) // seconds
|
||
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null)
|
||
|
||
// Overview, Search, Notifications state
|
||
const [auditLogs, setAuditLogs] = useState<any[]>([])
|
||
const [showSearchModal, setShowSearchModal] = useState(false)
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [showNotificationPanel, setShowNotificationPanel] = useState(false)
|
||
|
||
// Pod selection for bulk delete
|
||
const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
|
||
const [isDeletingPods, setIsDeletingPods] = useState(false)
|
||
|
||
// Deleted deployments history for restore
|
||
const [deletedDeployments, setDeletedDeployments] = useState<Array<{
|
||
name: string
|
||
namespace: string
|
||
deletedAt: string
|
||
spec: any
|
||
}>>([])
|
||
const [showDeletedDeployments, setShowDeletedDeployments] = useState(false)
|
||
|
||
// Load kubeconfig from manager settings or MongoDB (MongoDB is source of truth)
|
||
useEffect(() => {
|
||
// Priority: manager's personal kubeconfig > MongoDB active kubeconfig
|
||
if (managerKubeconfig) {
|
||
setKubeconfig(managerKubeconfig)
|
||
}
|
||
// MongoDB kubeconfigs will be loaded via loadSavedKubeconfigs
|
||
}, [managerKubeconfig])
|
||
|
||
// When a feature is disabled, switch away from its tab
|
||
useEffect(() => {
|
||
if (activeResourceTab === 'image-change-report' && !enableImageChangeReport) {
|
||
setActiveResourceTab('overview')
|
||
}
|
||
if (activeResourceTab === 'databases' && !enableDatabase) {
|
||
setActiveResourceTab('overview')
|
||
}
|
||
if (activeResourceTab === 'mongodb-wizard' && !enableMongoDBWizard) {
|
||
setActiveResourceTab('overview')
|
||
}
|
||
if (activeResourceTab === 'adminer-wizard' && !enableAdminerWizard) {
|
||
setActiveResourceTab('overview')
|
||
}
|
||
}, [enableImageChangeReport, enableDatabase, enableMongoDBWizard, enableAdminerWizard, activeResourceTab])
|
||
|
||
// Kubeconfig management removed - using ServiceAccount for cluster access
|
||
// No need to load saved kubeconfigs anymore
|
||
const loadSavedKubeconfigs = async () => {
|
||
// This function is kept for compatibility but does nothing
|
||
// Kubeconfig management has been removed in favor of ServiceAccount
|
||
console.log('ℹ️ Kubeconfig management removed - using ServiceAccount for cluster access')
|
||
}
|
||
|
||
const handleSaveKubeconfig = async () => {
|
||
if (!newKubeconfigName.trim() || !newKubeconfigContent.trim()) {
|
||
setErrorMessage('Name and kubeconfig content are required')
|
||
return
|
||
}
|
||
|
||
setIsSavingKubeconfig(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
const response = await fetch(`${apiUrl}/k8s/kubeconfigs`, {
|
||
method: 'POST',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
name: newKubeconfigName.trim(),
|
||
kubeconfig: newKubeconfigContent.trim(),
|
||
isActive: false
|
||
})
|
||
})
|
||
|
||
const data = await response.json()
|
||
if (data.success) {
|
||
await loadSavedKubeconfigs()
|
||
setNewKubeconfigName('')
|
||
setNewKubeconfigContent('')
|
||
setShowKubeconfigManager(false)
|
||
setErrorMessage('✅ Kubeconfig saved successfully!')
|
||
setTimeout(() => setErrorMessage(null), 3000)
|
||
} else {
|
||
setErrorMessage(data.error || 'Failed to save kubeconfig')
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage('Failed to save kubeconfig: ' + error.message)
|
||
} finally {
|
||
setIsSavingKubeconfig(false)
|
||
}
|
||
}
|
||
|
||
const handleActivateKubeconfig = async (id: string) => {
|
||
setIsConnecting(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
const response = await fetch(`${apiUrl}/k8s/kubeconfigs/${id}/activate`, {
|
||
method: 'POST',
|
||
headers: getAuthHeaders()
|
||
})
|
||
|
||
const data = await response.json()
|
||
if (data.success) {
|
||
setIsConnected(true)
|
||
setKubeconfig(data.kubeconfig.kubeconfig)
|
||
await loadSavedKubeconfigs()
|
||
setErrorMessage('✅ Successfully connected to cluster. Loading namespaces...')
|
||
try {
|
||
await loadNamespaces(true)
|
||
await loadData()
|
||
} catch (err) {
|
||
console.error('Error loading namespaces:', err)
|
||
}
|
||
setTimeout(() => {
|
||
setErrorMessage('✅ Successfully connected and loaded namespaces')
|
||
setTimeout(() => setErrorMessage(null), 3000)
|
||
}, 1000)
|
||
} else {
|
||
setErrorMessage(data.error || 'Failed to activate kubeconfig')
|
||
setIsConnected(false)
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage('Failed to activate kubeconfig: ' + error.message)
|
||
setIsConnected(false)
|
||
} finally {
|
||
setIsConnecting(false)
|
||
}
|
||
}
|
||
|
||
const handleDeleteKubeconfig = async (id: string) => {
|
||
if (!confirm('Are you sure you want to delete this kubeconfig?')) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${apiUrl}/k8s/kubeconfigs/${id}`, {
|
||
method: 'DELETE',
|
||
headers: getAuthHeaders()
|
||
})
|
||
|
||
const data = await response.json()
|
||
if (data.success) {
|
||
await loadSavedKubeconfigs()
|
||
// If deleted kubeconfig was active, clear connection
|
||
const wasActive = kubeconfigs.find(kc => kc.id === id)?.isActive
|
||
if (wasActive) {
|
||
setIsConnected(false)
|
||
setKubeconfig('')
|
||
}
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage('Failed to delete kubeconfig: ' + error.message)
|
||
}
|
||
}
|
||
|
||
// Clear validation/resolution results when kubeconfig changes
|
||
useEffect(() => {
|
||
if (validationResult) {
|
||
setValidationResult(null)
|
||
}
|
||
if (resolutionResult) {
|
||
setResolutionResult(null)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [kubeconfig])
|
||
|
||
// Set initial active tab when bulk update selections change
|
||
useEffect(() => {
|
||
if (showBulkUpdateWizard && bulkUpdateSelections.size > 0 && !bulkUpdateActiveTab) {
|
||
// Check if there are main containers or sidecar containers
|
||
let hasMainContainers = false
|
||
let hasSidecarContainers = false
|
||
|
||
deployments.forEach((deployment) => {
|
||
const key = `${deployment.namespace}/${deployment.name}`
|
||
if (bulkUpdateSelections.has(key)) {
|
||
deployment.containers.forEach((container) => {
|
||
const isSidecar = container.name.toLowerCase().includes('sidecar')
|
||
if (isSidecar) {
|
||
hasSidecarContainers = true
|
||
} else {
|
||
hasMainContainers = true
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
// Set to 'main' if main containers exist, otherwise 'sidecar' if sidecar containers exist
|
||
if (hasMainContainers) {
|
||
setBulkUpdateActiveTab('main')
|
||
} else if (hasSidecarContainers) {
|
||
setBulkUpdateActiveTab('sidecar')
|
||
}
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [showBulkUpdateWizard, bulkUpdateSelections.size])
|
||
|
||
const handleResolve = async () => {
|
||
if (!kubeconfig.trim()) {
|
||
setErrorMessage('Please paste or upload a kubeconfig file')
|
||
setResolutionResult(null)
|
||
return
|
||
}
|
||
|
||
setIsResolving(true)
|
||
setErrorMessage(null)
|
||
setResolutionResult(null)
|
||
|
||
try {
|
||
const response = await fetch(`${apiUrl}/k8s/resolve`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ kubeconfig }),
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (data.success) {
|
||
setResolutionResult({
|
||
resolved: data.resolved,
|
||
fixes: data.fixes || [],
|
||
remainingIssues: data.remainingIssues || [],
|
||
resolvedKubeconfig: data.resolvedKubeconfig,
|
||
hasChanges: data.hasChanges,
|
||
})
|
||
|
||
if (data.resolved && data.hasChanges) {
|
||
setErrorMessage(
|
||
`✅ Resolved ${data.fixes.length} issue(s). Review the changes below and click "Apply Fixes" to update.`
|
||
)
|
||
} else if (data.resolved) {
|
||
setErrorMessage('✅ No issues found to resolve')
|
||
} else {
|
||
setErrorMessage('⚠️ Some issues could not be auto-resolved')
|
||
}
|
||
|
||
// Auto-validate the resolved config
|
||
if (data.resolvedKubeconfig) {
|
||
const validateResponse = await fetch(`${apiUrl}/k8s/validate`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ kubeconfig: data.resolvedKubeconfig }),
|
||
})
|
||
|
||
const validateData = await validateResponse.json()
|
||
if (validateData.valid) {
|
||
setValidationResult({
|
||
valid: true,
|
||
info: validateData.info,
|
||
})
|
||
} else {
|
||
setValidationResult({
|
||
valid: false,
|
||
issues: validateData.issues,
|
||
suggestions: validateData.suggestions,
|
||
info: validateData.info,
|
||
})
|
||
}
|
||
}
|
||
} else {
|
||
setErrorMessage(data.message || 'Failed to resolve kubeconfig issues')
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage(error.message || 'Failed to resolve kubeconfig')
|
||
} finally {
|
||
setIsResolving(false)
|
||
}
|
||
}
|
||
|
||
const handleApplyFixes = () => {
|
||
if (resolutionResult && resolutionResult.resolvedKubeconfig) {
|
||
setKubeconfig(resolutionResult.resolvedKubeconfig)
|
||
setResolutionResult(null)
|
||
setErrorMessage('✅ Fixed kubeconfig applied. You can now validate or connect.')
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
}
|
||
}
|
||
|
||
const handleValidate = async () => {
|
||
if (!kubeconfig.trim()) {
|
||
setErrorMessage('Please paste or upload a kubeconfig file')
|
||
setValidationResult(null)
|
||
return
|
||
}
|
||
|
||
setIsValidating(true)
|
||
setErrorMessage(null)
|
||
setValidationResult(null)
|
||
|
||
try {
|
||
const response = await fetch(`${apiUrl}/k8s/validate`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ kubeconfig }),
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (data.valid) {
|
||
setValidationResult({
|
||
valid: true,
|
||
info: data.info,
|
||
})
|
||
setErrorMessage('✅ Kubeconfig is valid and ready to connect')
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
} else {
|
||
setValidationResult({
|
||
valid: false,
|
||
issues: data.issues || [],
|
||
suggestions: data.suggestions || [],
|
||
info: data.info,
|
||
})
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage(error.message || 'Failed to validate kubeconfig')
|
||
setValidationResult({
|
||
valid: false,
|
||
issues: ['Validation request failed'],
|
||
suggestions: ['Check your connection to the API server'],
|
||
})
|
||
} finally {
|
||
setIsValidating(false)
|
||
}
|
||
}
|
||
|
||
const handleConnect = async () => {
|
||
if (!kubeconfig.trim()) {
|
||
setErrorMessage('Please paste or upload a kubeconfig file')
|
||
return
|
||
}
|
||
|
||
setIsConnecting(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
const response = await fetch(`${apiUrl}/k8s/connect`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ kubeconfig }),
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (response.ok && data.success) {
|
||
setIsConnected(true)
|
||
setValidationResult({
|
||
valid: true,
|
||
info: data.info,
|
||
})
|
||
|
||
// Save kubeconfig to MongoDB for retention (MongoDB is source of truth)
|
||
try {
|
||
const saveResponse = await fetch(`${apiUrl}/k8s/kubeconfigs`, {
|
||
method: 'POST',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
name: `Kubeconfig ${new Date().toLocaleString()}`,
|
||
kubeconfig: kubeconfig,
|
||
isActive: true // Set as active since user just connected with it
|
||
})
|
||
})
|
||
const saveData = await saveResponse.json()
|
||
if (saveData.success) {
|
||
console.log('✅ Kubeconfig saved to MongoDB for retention')
|
||
// Reload kubeconfigs list to show the newly saved one
|
||
await loadSavedKubeconfigs()
|
||
// Clear localStorage since MongoDB is now the source of truth
|
||
storage.removeLocal(STORAGE_KEYS.KUBECONFIG)
|
||
}
|
||
} catch (saveError) {
|
||
console.error('Failed to save kubeconfig to MongoDB:', saveError)
|
||
// Fallback to localStorage only if MongoDB save fails
|
||
storage.setLocal(STORAGE_KEYS.KUBECONFIG, kubeconfig)
|
||
}
|
||
|
||
setErrorMessage('✅ Successfully connected to cluster. Kubeconfig saved to MongoDB. Loading namespaces...')
|
||
// Load namespaces immediately after connection (skip connection check since we just connected)
|
||
try {
|
||
await loadNamespaces(true)
|
||
// Always load data after namespaces are loaded (defaults to 'all' to show all resources)
|
||
await loadData()
|
||
} catch (err) {
|
||
console.error('Error loading namespaces after connection:', err)
|
||
setErrorMessage('✅ Connected, but failed to load namespaces. Click "Load Namespaces" to retry.')
|
||
}
|
||
setTimeout(() => {
|
||
if (namespaces.length > 0) {
|
||
setErrorMessage('✅ Successfully connected and loaded namespaces')
|
||
} else {
|
||
setErrorMessage('✅ Connected. Click "Load Namespaces" to fetch namespaces.')
|
||
}
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
}, 1000)
|
||
} else {
|
||
// Handle validation errors or connection errors
|
||
if (data.issues && data.suggestions) {
|
||
setValidationResult({
|
||
valid: data.valid || false,
|
||
issues: data.issues,
|
||
suggestions: data.suggestions,
|
||
info: data.info,
|
||
})
|
||
}
|
||
setErrorMessage(data.message || 'Failed to connect to cluster')
|
||
setIsConnected(false)
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage(error.message || 'Failed to connect to cluster')
|
||
setIsConnected(false)
|
||
} finally {
|
||
setIsConnecting(false)
|
||
}
|
||
}
|
||
|
||
const loadNamespaces = async (skipConnectionCheck = false) => {
|
||
if (!skipConnectionCheck && !isConnected) {
|
||
setErrorMessage('Please connect to a Kubernetes cluster first')
|
||
return
|
||
}
|
||
|
||
setIsLoading(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
console.log('📋 Loading namespaces from:', `${apiUrl}/k8s/namespaces`)
|
||
const response = await fetch(`${apiUrl}/k8s/namespaces`, {
|
||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }))
|
||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
console.log('📋 Namespaces response:', data)
|
||
|
||
if (data.success && data.namespaces) {
|
||
setNamespaces(data.namespaces)
|
||
setIsConnected(true) // ✅ Mark as connected after successfully loading namespaces
|
||
|
||
// Always use deployed namespace (from ServiceAccount)
|
||
const namespaceToUse = data.podNamespace || (data.namespaces.length > 0 ? data.namespaces[0].name : null);
|
||
|
||
if (namespaceToUse) {
|
||
setDeployedNamespace(namespaceToUse)
|
||
setSelectedNamespace(namespaceToUse)
|
||
console.log('📌 Using deployed namespace:', namespaceToUse)
|
||
// Automatically load resources for the deployed namespace
|
||
console.log('📦 Loading resources from deployed namespace:', namespaceToUse)
|
||
setTimeout(() => {
|
||
loadData()
|
||
}, 100)
|
||
} else {
|
||
setErrorMessage('⚠️ No namespace found. Please check POD_NAMESPACE configuration.')
|
||
}
|
||
} else {
|
||
setErrorMessage(data.message || data.error || 'Failed to load namespaces')
|
||
}
|
||
} catch (error: any) {
|
||
console.error('❌ Failed to load namespaces:', error)
|
||
setErrorMessage(error.message || 'Failed to load namespaces. Please check your connection and try again.')
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadNodes = async () => {
|
||
if (!isConnected) return
|
||
try {
|
||
const response = await fetch(`${apiUrl}/k8s/nodes`)
|
||
const data = await response.json()
|
||
if (response.ok && data?.nodes) {
|
||
setNodes(data.nodes)
|
||
} else {
|
||
console.warn('⚠️ Failed to load nodes:', data?.message || data?.error)
|
||
setNodes([])
|
||
}
|
||
} catch (error: any) {
|
||
console.error('❌ Failed to load nodes:', error)
|
||
setNodes([])
|
||
}
|
||
}
|
||
|
||
const loadImageChangeReport = async () => {
|
||
if (!apiUrl) return
|
||
const namespaceToUse = deployedNamespace || selectedNamespace
|
||
if (!namespaceToUse || namespaceToUse === 'all') {
|
||
setErrorMessage('No namespace available. Connect to the cluster so the report is scoped to your deployed namespace.')
|
||
return
|
||
}
|
||
setImageChangeReportLoading(true)
|
||
setImageChangeReportData(null)
|
||
setErrorMessage(null)
|
||
try {
|
||
const params = new URLSearchParams({ date: imageChangeReportDate, namespace: namespaceToUse })
|
||
const response = await fetch(`${apiUrl}/k8s/image-change-report?${params}`)
|
||
const data = await response.json()
|
||
if (response.ok && data.success) {
|
||
setImageChangeReportData({
|
||
date: data.date,
|
||
byDeployment: data.byDeployment || [],
|
||
totalChanges: data.totalChanges ?? 0,
|
||
recentInNamespace: data.recentInNamespace || [],
|
||
currentDeployments: data.currentDeployments || [],
|
||
})
|
||
} else {
|
||
setErrorMessage(data.message || 'Failed to load image change report')
|
||
}
|
||
} catch (err: any) {
|
||
setErrorMessage(err.message || 'Failed to load image change report')
|
||
} finally {
|
||
setImageChangeReportLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadData = async () => {
|
||
if (!isConnected) {
|
||
console.log('⚠️ Not connected, skipping loadData')
|
||
return
|
||
}
|
||
|
||
// Always use deployed namespace (from ServiceAccount)
|
||
const namespaceToUse = deployedNamespace || selectedNamespace
|
||
if (!namespaceToUse || namespaceToUse === 'all') {
|
||
console.log('⚠️ No namespace available, waiting for namespace detection...')
|
||
// If we have apiUrl but no namespace yet, try to load namespaces
|
||
if (apiUrl && !deployedNamespace) {
|
||
loadNamespaces(true)
|
||
}
|
||
// Don't clear existing data if we're just waiting for namespace
|
||
// This prevents "no resources" flash when namespace is temporarily unavailable
|
||
return
|
||
}
|
||
|
||
setIsLoading(true)
|
||
setErrorMessage(null)
|
||
|
||
// Don't clear data here - let the API responses determine what to show
|
||
// This prevents "no resources" flash during refresh
|
||
|
||
try {
|
||
// Always use deployed namespace
|
||
const namespaceParam = namespaceToUse
|
||
console.log('📦 Loading resources from deployed namespace:', namespaceParam)
|
||
|
||
const [deploymentsRes, servicesRes, podsRes, configmapsRes, secretsRes, eventsRes, cronjobsRes, jobsRes, ingressesRes, pvcsRes] = await Promise.all([
|
||
fetch(`${apiUrl}/k8s/deployments?namespace=${namespaceParam}`),
|
||
fetch(`${apiUrl}/k8s/services?namespace=${namespaceParam}`),
|
||
fetch(`${apiUrl}/k8s/pods?namespace=${namespaceParam}`),
|
||
fetch(`${apiUrl}/k8s/configmaps?namespace=${namespaceParam}`).catch((err) => {
|
||
console.warn('⚠️ ConfigMaps fetch failed:', err);
|
||
return { ok: false, status: 500, json: async () => ({ success: false, configmaps: [], error: err.message }) };
|
||
}),
|
||
fetch(`${apiUrl}/k8s/secrets?namespace=${namespaceParam}`).catch((err) => {
|
||
console.warn('⚠️ Secrets fetch failed:', err);
|
||
return { ok: false, status: 500, json: async () => ({ success: false, secrets: [], error: err.message }) };
|
||
}),
|
||
fetch(`${apiUrl}/k8s/events?namespace=${namespaceParam}`).catch((err) => {
|
||
console.warn('⚠️ Events fetch failed:', err);
|
||
return { ok: false, status: 500, json: async () => ({ success: false, events: [], error: err.message }) };
|
||
}),
|
||
fetch(`${apiUrl}/k8s/cronjobs?namespace=${namespaceParam}`).catch((err) => {
|
||
console.warn('⚠️ CronJobs fetch failed:', err);
|
||
return { ok: false, status: 500, json: async () => ({ success: false, cronjobs: [], error: err.message }) };
|
||
}),
|
||
fetch(`${apiUrl}/k8s/jobs?namespace=${namespaceParam}`).catch((err) => {
|
||
console.warn('⚠️ Jobs fetch failed:', err);
|
||
return { ok: false, status: 500, json: async () => ({ success: false, jobs: [], error: err.message }) };
|
||
}),
|
||
fetch(`${apiUrl}/k8s/ingresses?namespace=${namespaceParam}`).catch((err) => {
|
||
console.warn('⚠️ Ingresses fetch failed:', err);
|
||
return { ok: false, status: 500, json: async () => ({ success: false, ingresses: [], error: err.message }) };
|
||
}),
|
||
fetch(`${apiUrl}/k8s/pvcs?namespace=${namespaceParam}`).catch((err) => {
|
||
console.warn('⚠️ PVCs fetch failed:', err);
|
||
return { ok: false, status: 500, json: async () => ({ success: false, pvcs: [], error: err.message }) };
|
||
}),
|
||
])
|
||
|
||
const deploymentsData = await deploymentsRes.json()
|
||
const servicesData = await servicesRes.json()
|
||
const podsData = await podsRes.json()
|
||
|
||
console.log('📦 Deployments response:', deploymentsData)
|
||
console.log('📦 Services response:', servicesData)
|
||
console.log('📦 Pods response:', podsData)
|
||
|
||
if (deploymentsData.success) {
|
||
console.log('✅ Setting deployments:', deploymentsData.deployments?.length || 0)
|
||
setDeployments(deploymentsData.deployments || [])
|
||
} else {
|
||
console.error('❌ Deployments API error:', deploymentsData.error || deploymentsData.message)
|
||
// Only clear data on client errors (4xx), keep existing data on server errors (5xx) or network issues
|
||
// This prevents "no resources" flash during refresh when there are transient errors
|
||
if (deploymentsRes.status >= 400 && deploymentsRes.status < 500) {
|
||
setDeployments([])
|
||
} else {
|
||
console.warn('⚠️ Keeping existing deployments due to server/network error')
|
||
}
|
||
}
|
||
|
||
if (servicesData.success) {
|
||
console.log('✅ Setting services:', servicesData.services?.length || 0)
|
||
setServices(servicesData.services || [])
|
||
} else {
|
||
console.error('❌ Services API error:', servicesData.error || servicesData.message)
|
||
// Only clear data on client errors (4xx), keep existing data on server errors (5xx) or network issues
|
||
if (servicesRes.status >= 400 && servicesRes.status < 500) {
|
||
setServices([])
|
||
} else {
|
||
console.warn('⚠️ Keeping existing services due to server/network error')
|
||
}
|
||
}
|
||
|
||
if (podsData.success) {
|
||
const podsArray = podsData.pods || []
|
||
|
||
// Remove duplicates based on namespace + name
|
||
const uniquePods = podsArray.reduce((acc: Map<string, Pod>, pod: Pod) => {
|
||
const key = `${pod.namespace}/${pod.name}`
|
||
if (!acc.has(key)) {
|
||
acc.set(key, pod)
|
||
} else {
|
||
console.warn(`⚠️ Duplicate pod found: ${key}`)
|
||
}
|
||
return acc
|
||
}, new Map<string, Pod>())
|
||
|
||
const deduplicatedPods: Pod[] = Array.from(uniquePods.values())
|
||
|
||
// Filter by namespace if a specific namespace is selected (not 'all')
|
||
const filteredPods: Pod[] = selectedNamespace && selectedNamespace !== 'all'
|
||
? deduplicatedPods.filter((pod: Pod) => pod.namespace === selectedNamespace)
|
||
: deduplicatedPods
|
||
|
||
console.log('✅ Setting pods:', {
|
||
original: podsArray.length,
|
||
afterDedup: deduplicatedPods.length,
|
||
afterFilter: filteredPods.length,
|
||
namespace: namespaceParam,
|
||
selectedNamespace: selectedNamespace
|
||
})
|
||
|
||
if (podsArray.length !== filteredPods.length) {
|
||
console.warn(`⚠️ Pod count mismatch: API returned ${podsArray.length}, displaying ${filteredPods.length}`)
|
||
}
|
||
|
||
setPods(filteredPods)
|
||
} else {
|
||
console.error('❌ Pods API error:', podsData.error || podsData.message)
|
||
// Only clear data on client errors (4xx), keep existing data on server errors (5xx) or network issues
|
||
if (podsRes.status >= 400 && podsRes.status < 500) {
|
||
setPods([])
|
||
} else {
|
||
console.warn('⚠️ Keeping existing pods due to server/network error')
|
||
}
|
||
}
|
||
|
||
const configmapsData = await configmapsRes.json()
|
||
if (configmapsData.success) {
|
||
console.log('✅ Setting configmaps:', configmapsData.configmaps?.length || 0)
|
||
setConfigmaps(configmapsData.configmaps || [])
|
||
} else {
|
||
console.error('❌ ConfigMaps API error:', configmapsData.error || configmapsData.message)
|
||
setConfigmaps([])
|
||
}
|
||
|
||
const secretsData = await secretsRes.json()
|
||
if (secretsData.success) {
|
||
setSecrets(secretsData.secrets || [])
|
||
} else {
|
||
setSecrets([])
|
||
}
|
||
|
||
const eventsData = await eventsRes.json()
|
||
if (eventsData.success) {
|
||
console.log('✅ Setting events:', eventsData.events?.length || 0)
|
||
setEvents(eventsData.events || [])
|
||
} else {
|
||
console.error('❌ Events API error:', eventsData.error || eventsData.message)
|
||
setEvents([])
|
||
}
|
||
|
||
// Handle CronJobs
|
||
try {
|
||
const cronjobsData = await cronjobsRes.json()
|
||
if (cronjobsData.success) {
|
||
console.log('✅ Setting cronjobs:', cronjobsData.cronjobs?.length || 0)
|
||
setCronjobs(cronjobsData.cronjobs || [])
|
||
} else {
|
||
console.error('❌ CronJobs API error:', cronjobsData.error || cronjobsData.message, cronjobsData.details)
|
||
// Don't show error if it's just that there are no cronjobs (404 or empty list)
|
||
if (cronjobsRes.status !== 404 && !cronjobsData.message?.includes('not found')) {
|
||
console.warn('⚠️ CronJobs fetch warning:', cronjobsData.message)
|
||
}
|
||
setCronjobs([])
|
||
}
|
||
} catch (cronjobsError: any) {
|
||
console.error('❌ Failed to parse CronJobs response:', cronjobsError)
|
||
setCronjobs([])
|
||
}
|
||
|
||
// Handle Jobs
|
||
try {
|
||
const jobsData = await jobsRes.json()
|
||
if (jobsData.success) {
|
||
console.log('✅ Setting jobs:', jobsData.jobs?.length || 0)
|
||
setJobs(jobsData.jobs || [])
|
||
} else {
|
||
console.error('❌ Jobs API error:', jobsData.error || jobsData.message, jobsData.details)
|
||
// Don't show error if it's just that there are no jobs (404 or empty list)
|
||
if (jobsRes.status !== 404 && !jobsData.message?.includes('not found')) {
|
||
console.warn('⚠️ Jobs fetch warning:', jobsData.message)
|
||
}
|
||
setJobs([])
|
||
}
|
||
} catch (jobsError: any) {
|
||
console.error('❌ Failed to parse Jobs response:', jobsError)
|
||
setJobs([])
|
||
}
|
||
|
||
// Handle Ingresses
|
||
try {
|
||
const ingressesData = await ingressesRes.json()
|
||
if (ingressesData.success) {
|
||
setIngresses(ingressesData.ingresses || [])
|
||
} else {
|
||
setIngresses([])
|
||
}
|
||
} catch (ingressesError: any) {
|
||
console.error('❌ Failed to parse Ingresses response:', ingressesError)
|
||
setIngresses([])
|
||
}
|
||
|
||
// Handle PVCs
|
||
try {
|
||
const pvcsData = await pvcsRes.json()
|
||
if (pvcsData.success) {
|
||
setPvcs(pvcsData.pvcs || [])
|
||
} else {
|
||
setPvcs([])
|
||
}
|
||
} catch (pvcsError: any) {
|
||
console.error('❌ Failed to parse PVCs response:', pvcsError)
|
||
setPvcs([])
|
||
}
|
||
|
||
// Check for HTTP errors
|
||
if (!deploymentsRes.ok) {
|
||
console.error('❌ Deployments HTTP error:', deploymentsRes.status, deploymentsRes.statusText)
|
||
setErrorMessage(`Failed to fetch deployments: ${deploymentsRes.status} ${deploymentsRes.statusText}`)
|
||
}
|
||
if (!servicesRes.ok) {
|
||
console.error('❌ Services HTTP error:', servicesRes.status, servicesRes.statusText)
|
||
setErrorMessage(`Failed to fetch services: ${servicesRes.status} ${servicesRes.statusText}`)
|
||
}
|
||
if (!podsRes.ok) {
|
||
console.error('❌ Pods HTTP error:', podsRes.status, podsRes.statusText)
|
||
setErrorMessage(`Failed to fetch pods: ${podsRes.status} ${podsRes.statusText}`)
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage(error.message || 'Failed to load cluster data')
|
||
} finally {
|
||
setIsLoading(false)
|
||
setLastRefreshed(new Date())
|
||
}
|
||
}
|
||
|
||
|
||
// Automatically load namespaces and resources on mount when apiUrl is available
|
||
useEffect(() => {
|
||
if (apiUrl && !isConnected && !isConnecting) {
|
||
console.log('🚀 Auto-connecting to Kubernetes cluster via ServiceAccount...')
|
||
loadNamespaces(true) // Skip connection check since we're auto-connecting
|
||
}
|
||
}, [apiUrl]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
useEffect(() => {
|
||
if (isConnected && selectedNamespace) {
|
||
console.log('🔄 Namespace changed to:', selectedNamespace, '- Loading resources...')
|
||
// Clear previous data when namespace changes
|
||
setDeployments([])
|
||
setPods([])
|
||
setNodes([])
|
||
setServices([])
|
||
setConfigmaps([])
|
||
setEvents([])
|
||
setCronjobs([])
|
||
setJobs([])
|
||
// Load new data for the selected namespace
|
||
loadData()
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [selectedNamespace, isConnected])
|
||
|
||
useEffect(() => {
|
||
if (isConnected) {
|
||
loadNodes()
|
||
} else {
|
||
setNodes([])
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isConnected])
|
||
|
||
// Auto-refresh effect
|
||
useEffect(() => {
|
||
if (!autoRefresh || !isConnected) return
|
||
|
||
const interval = setInterval(async () => {
|
||
console.log('🔄 Auto-refreshing data...')
|
||
const currentTab = activeResourceTab
|
||
await loadData()
|
||
setActiveResourceTab(currentTab)
|
||
setLastRefreshed(new Date())
|
||
}, refreshInterval * 1000)
|
||
|
||
return () => clearInterval(interval)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [autoRefresh, refreshInterval, isConnected, deployedNamespace, selectedNamespace])
|
||
|
||
const handleImageChange = (
|
||
namespace: string,
|
||
deploymentName: string,
|
||
containerName: string,
|
||
newImage: string,
|
||
originalImage: string
|
||
) => {
|
||
const key = `${namespace}/${deploymentName}/${containerName}`
|
||
const newUpdates = new Map(imageUpdates)
|
||
|
||
if (newImage === originalImage) {
|
||
newUpdates.delete(key)
|
||
} else {
|
||
newUpdates.set(key, {
|
||
namespace: namespace,
|
||
deployment: deploymentName,
|
||
container: containerName,
|
||
image: newImage,
|
||
originalImage,
|
||
})
|
||
}
|
||
|
||
setImageUpdates(newUpdates)
|
||
}
|
||
|
||
// Helper function to extract base image (without tag)
|
||
const getBaseImage = (image: string): string => {
|
||
// Remove tag if present (format: image:tag)
|
||
if (image.includes(':')) {
|
||
// Check if it's a port number (registry:port/image:tag)
|
||
const parts = image.split(':')
|
||
if (parts.length > 2) {
|
||
// Has port, get everything before the last colon
|
||
const lastColonIndex = image.lastIndexOf(':')
|
||
return image.substring(0, lastColonIndex)
|
||
} else {
|
||
// Simple format: image:tag
|
||
return parts[0]
|
||
}
|
||
}
|
||
// Remove digest if present (format: image@sha256:...)
|
||
if (image.includes('@')) {
|
||
return image.split('@')[0]
|
||
}
|
||
return image
|
||
}
|
||
|
||
// Helper function to apply tag-only update
|
||
const applyTagOnlyUpdate = (originalImage: string, newTag: string): string => {
|
||
const baseImage = getBaseImage(originalImage)
|
||
return `${baseImage}:${newTag}`
|
||
}
|
||
|
||
const handleBulkImageUpdate = (newImage: string) => {
|
||
const newUpdates = new Map<string, ImageUpdate>()
|
||
|
||
deployments.forEach((deployment) => {
|
||
deployment.containers.forEach((container) => {
|
||
const key = `${selectedNamespace}/${deployment.name}/${container.name}`
|
||
newUpdates.set(key, {
|
||
namespace: selectedNamespace,
|
||
deployment: deployment.name,
|
||
container: container.name,
|
||
image: newImage,
|
||
originalImage: container.image,
|
||
})
|
||
})
|
||
})
|
||
|
||
setImageUpdates(newUpdates)
|
||
}
|
||
|
||
const handleSaveSingleImage = async (namespace: string, deployment: string, container: string, newImage: string, originalImage: string) => {
|
||
if (newImage === originalImage) {
|
||
setErrorMessage('Image unchanged')
|
||
setTimeout(() => setErrorMessage(null), 2000)
|
||
return
|
||
}
|
||
|
||
if (!newImage || newImage.trim() === '') {
|
||
setErrorMessage('Please enter a valid image name')
|
||
setTimeout(() => setErrorMessage(null), 2000)
|
||
return
|
||
}
|
||
|
||
// Validate required fields
|
||
const trimmedNamespace = namespace?.trim()
|
||
const trimmedDeployment = deployment?.trim()
|
||
const trimmedContainer = container?.trim()
|
||
const trimmedImage = newImage.trim()
|
||
|
||
if (!trimmedNamespace || !trimmedDeployment || !trimmedContainer) {
|
||
setErrorMessage('Missing required fields: namespace, deployment, or container name')
|
||
setTimeout(() => setErrorMessage(null), 3000)
|
||
return
|
||
}
|
||
|
||
// Ensure deployment name doesn't contain namespace (some APIs return it as namespace/name)
|
||
let deploymentName = trimmedDeployment
|
||
if (trimmedDeployment.includes('/')) {
|
||
const parts = trimmedDeployment.split('/').filter(p => p && p.trim() !== '')
|
||
deploymentName = parts.length > 0 ? parts[parts.length - 1] : trimmedDeployment
|
||
console.log('Extracted deployment name:', { original: trimmedDeployment, extracted: deploymentName, parts })
|
||
}
|
||
|
||
// Final validation after extraction
|
||
if (!deploymentName || deploymentName.trim() === '') {
|
||
setErrorMessage('Invalid deployment name after processing')
|
||
setTimeout(() => setErrorMessage(null), 3000)
|
||
return
|
||
}
|
||
|
||
setIsUpdating(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
const updatePayload = {
|
||
namespace: String(trimmedNamespace),
|
||
deployment: String(deploymentName),
|
||
container: String(trimmedContainer),
|
||
image: String(trimmedImage)
|
||
}
|
||
|
||
console.log('Saving image update:', {
|
||
namespace: trimmedNamespace,
|
||
deployment: deploymentName,
|
||
container: trimmedContainer,
|
||
image: trimmedImage,
|
||
originalDeployment: deployment,
|
||
payload: updatePayload
|
||
})
|
||
|
||
const response = await fetch(`${apiUrl}/k8s/update-images`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
updates: [updatePayload],
|
||
source: 'ui-single'
|
||
}),
|
||
})
|
||
|
||
// Check if response is ok before parsing JSON
|
||
if (!response.ok) {
|
||
const errorText = await response.text()
|
||
let errorData
|
||
try {
|
||
errorData = JSON.parse(errorText)
|
||
} catch {
|
||
errorData = { message: errorText || `HTTP ${response.status}: ${response.statusText}` }
|
||
}
|
||
throw new Error(errorData.error || errorData.message || `HTTP ${response.status}: ${response.statusText}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
|
||
if (data.success || (data.updated && data.updated > 0)) {
|
||
setErrorMessage(`✅ Successfully updated ${container} image in ${deployment}`)
|
||
// Remove from pending updates
|
||
const key = `${namespace}/${deployment}/${container}`
|
||
const newUpdates = new Map(imageUpdates)
|
||
newUpdates.delete(key)
|
||
setImageUpdates(newUpdates)
|
||
await loadData()
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
} else {
|
||
// Check if there are errors in the response
|
||
const errorMsg = data.errors && data.errors.length > 0
|
||
? data.errors[0].error || data.errors[0].message
|
||
: data.error || data.message
|
||
const errorDetails = data.errors && data.errors.length > 0 && data.errors[0].deployment
|
||
? ` (${data.errors[0].deployment}/${data.errors[0].container})`
|
||
: ''
|
||
setErrorMessage(
|
||
`❌ Failed to update image${errorDetails}: ${errorMsg || 'Unknown error'}`
|
||
)
|
||
setTimeout(() => setErrorMessage(null), 10000)
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Error updating image:', error)
|
||
setErrorMessage(`❌ Failed to update image: ${error.message || 'Network error. Please check if the API server is running and you are connected to the cluster.'}`)
|
||
setTimeout(() => setErrorMessage(null), 10000)
|
||
} finally {
|
||
setIsUpdating(false)
|
||
}
|
||
}
|
||
|
||
const handleUpdateImages = async () => {
|
||
console.log('🔄 handleUpdateImages called')
|
||
console.log('📦 imageUpdates size:', imageUpdates.size)
|
||
console.log('📦 imageUpdates:', Array.from(imageUpdates.entries()))
|
||
|
||
if (imageUpdates.size === 0) {
|
||
setErrorMessage('No image updates to apply')
|
||
return
|
||
}
|
||
|
||
setIsUpdating(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
const updates = Array.from(imageUpdates.values())
|
||
console.log('📋 All updates:', updates)
|
||
|
||
// If selective mode, only update selected deployments
|
||
let updatesToApply = updates
|
||
if (updateMode === 'selective') {
|
||
console.log('🎯 Selective mode - selectedDeployments:', Array.from(selectedDeployments))
|
||
updatesToApply = updates.filter((update) => {
|
||
const key = `${update.namespace}/${update.deployment}`
|
||
return selectedDeployments.has(key)
|
||
})
|
||
console.log('✅ Filtered updates to apply:', updatesToApply)
|
||
}
|
||
|
||
if (updatesToApply.length === 0) {
|
||
console.log('⚠️ No deployments selected')
|
||
setErrorMessage('Please select deployments to update or switch to bulk mode')
|
||
setIsUpdating(false)
|
||
return
|
||
}
|
||
|
||
console.log('🚀 Making API call to update images:', updatesToApply)
|
||
const response = await fetch(`${apiUrl}/k8s/update-images`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ updates: updatesToApply, source: updateMode === 'selective' ? 'ui-selective' : 'ui-bulk' }),
|
||
})
|
||
console.log('📡 API response status:', response.status)
|
||
|
||
// Check if response is ok before parsing JSON
|
||
if (!response.ok) {
|
||
const errorText = await response.text()
|
||
let errorData
|
||
try {
|
||
errorData = JSON.parse(errorText)
|
||
} catch {
|
||
errorData = { message: errorText || `HTTP ${response.status}: ${response.statusText}` }
|
||
}
|
||
throw new Error(errorData.error || errorData.message || `HTTP ${response.status}: ${response.statusText}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
|
||
if (data.success) {
|
||
// All updates succeeded
|
||
setErrorMessage(`✅ Successfully updated ${data.updated || updatesToApply.length} container(s)`)
|
||
setImageUpdates(new Map())
|
||
setSelectedDeployments(new Set())
|
||
await loadData()
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
} else if (data.updated && data.updated > 0) {
|
||
// Partial success - some succeeded, some failed
|
||
const errorDetails = data.errors && data.errors.length > 0
|
||
? data.errors.map((e: any) => `${e.deployment}/${e.container}: ${e.error}`).join('; ')
|
||
: 'See details above'
|
||
setErrorMessage(
|
||
`⚠️ Partial update: ${data.updated} succeeded, ${data.failed || 0} failed. ${errorDetails}`
|
||
)
|
||
// Clear successful updates from the pending list
|
||
if (data.results && Array.isArray(data.results)) {
|
||
const newUpdates = new Map(imageUpdates)
|
||
data.results.forEach((result: any) => {
|
||
const key = `${result.namespace}/${result.deployment}/${result.container}`
|
||
newUpdates.delete(key)
|
||
})
|
||
setImageUpdates(newUpdates)
|
||
}
|
||
await loadData()
|
||
setTimeout(() => setErrorMessage(null), 8000)
|
||
} else {
|
||
// All updates failed
|
||
const errorMsg = data.errors && data.errors.length > 0
|
||
? data.errors.map((e: any) => `${e.deployment}/${e.container}: ${e.error}`).join('; ')
|
||
: data.error || data.message || 'Unknown error'
|
||
setErrorMessage(`❌ Failed to update images: ${errorMsg}`)
|
||
setTimeout(() => setErrorMessage(null), 10000)
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Error updating images:', error)
|
||
setErrorMessage(`❌ Failed to update images: ${error.message || 'Network error. Please check if the API server is running and you are connected to the cluster.'}`)
|
||
setTimeout(() => setErrorMessage(null), 10000)
|
||
} finally {
|
||
setIsUpdating(false)
|
||
}
|
||
}
|
||
|
||
const toggleDeploymentSelection = (namespace: string, deploymentName: string) => {
|
||
const key = `${namespace}/${deploymentName}`
|
||
const newSelected = new Set(selectedDeployments)
|
||
|
||
if (newSelected.has(key)) {
|
||
newSelected.delete(key)
|
||
} else {
|
||
newSelected.add(key)
|
||
}
|
||
|
||
setSelectedDeployments(newSelected)
|
||
}
|
||
|
||
// Bulk Update Wizard Functions
|
||
const handleSelectAllBulkUpdate = () => {
|
||
if (bulkUpdateSelections.size === deployments.length) {
|
||
setBulkUpdateSelections(new Set())
|
||
} else {
|
||
const allKeys = new Set(deployments.map(d => `${d.namespace}/${d.name}`))
|
||
setBulkUpdateSelections(allKeys)
|
||
}
|
||
}
|
||
|
||
const toggleBulkUpdateSelection = (namespace: string, name: string) => {
|
||
const key = `${namespace}/${name}`
|
||
const newSelected = new Set(bulkUpdateSelections)
|
||
|
||
if (newSelected.has(key)) {
|
||
newSelected.delete(key)
|
||
} else {
|
||
newSelected.add(key)
|
||
}
|
||
|
||
setBulkUpdateSelections(newSelected)
|
||
}
|
||
|
||
const handleBulkUpdateApply = async () => {
|
||
if (bulkUpdateSelections.size === 0) {
|
||
setErrorMessage('Please select at least one deployment')
|
||
return
|
||
}
|
||
|
||
if (!bulkUpdateActiveTab || (bulkUpdateActiveTab !== 'main' && bulkUpdateActiveTab !== 'sidecar')) {
|
||
setErrorMessage('Please select a container tab (Main or Sidecar)')
|
||
return
|
||
}
|
||
|
||
const containerImage = bulkUpdateContainerImages.get(bulkUpdateActiveTab) || ''
|
||
const containerTagOnly = bulkUpdateContainerTagOnly.get(bulkUpdateActiveTab) || false
|
||
|
||
if (!containerImage.trim()) {
|
||
setErrorMessage('Please enter a new ' + (containerTagOnly ? 'tag' : 'image') + ' for ' + bulkUpdateActiveTab + ' containers')
|
||
return
|
||
}
|
||
|
||
setIsUpdating(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
// Build updates for all selected deployments, but only for containers matching the active tab type
|
||
const updates: ImageUpdate[] = []
|
||
const isSidecarTab = bulkUpdateActiveTab === 'sidecar'
|
||
|
||
deployments.forEach((deployment) => {
|
||
const key = `${deployment.namespace}/${deployment.name}`
|
||
if (bulkUpdateSelections.has(key)) {
|
||
deployment.containers.forEach((container) => {
|
||
// Only update containers matching the active tab type (sidecar vs main)
|
||
const isContainerSidecar = container.name.toLowerCase().includes('sidecar')
|
||
if (isSidecarTab === isContainerSidecar) {
|
||
// If tag-only mode, apply the tag to the base image
|
||
const finalImage = containerTagOnly
|
||
? applyTagOnlyUpdate(container.image, containerImage.trim())
|
||
: containerImage.trim()
|
||
|
||
updates.push({
|
||
namespace: deployment.namespace,
|
||
deployment: deployment.name,
|
||
container: container.name,
|
||
image: finalImage,
|
||
originalImage: container.image,
|
||
})
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
const response = await fetch(`${apiUrl}/k8s/update-images`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ updates, source: 'ui-bulk' }),
|
||
})
|
||
|
||
// Check if response is ok before parsing JSON
|
||
if (!response.ok) {
|
||
const errorText = await response.text()
|
||
let errorData
|
||
try {
|
||
errorData = JSON.parse(errorText)
|
||
} catch {
|
||
errorData = { message: errorText || `HTTP ${response.status}: ${response.statusText}` }
|
||
}
|
||
throw new Error(errorData.error || errorData.message || `HTTP ${response.status}: ${response.statusText}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
|
||
if (data.success) {
|
||
// All updates succeeded
|
||
setErrorMessage(`✅ Successfully updated ${data.updated || updates.length} container(s)`)
|
||
setShowBulkUpdateWizard(false)
|
||
setBulkUpdateSelections(new Set())
|
||
setBulkUpdateImage('')
|
||
setBulkUpdateTagOnly(false)
|
||
setBulkUpdateActiveTab('')
|
||
setBulkUpdateContainerImages(new Map())
|
||
setBulkUpdateContainerTagOnly(new Map())
|
||
setBulkUpdateStep('select')
|
||
await loadData()
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
} else if (data.updated && data.updated > 0) {
|
||
// Partial success - some succeeded, some failed
|
||
const errorDetails = data.errors && data.errors.length > 0
|
||
? data.errors.map((e: any) => `${e.deployment}/${e.container}: ${e.error}`).join('; ')
|
||
: 'See details above'
|
||
setErrorMessage(
|
||
`⚠️ Partial update: ${data.updated} succeeded, ${data.failed || 0} failed. ${errorDetails}`
|
||
)
|
||
setShowBulkUpdateWizard(false)
|
||
setBulkUpdateSelections(new Set())
|
||
setBulkUpdateImage('')
|
||
setBulkUpdateTagOnly(false)
|
||
setBulkUpdateActiveTab('')
|
||
setBulkUpdateContainerImages(new Map())
|
||
setBulkUpdateContainerTagOnly(new Map())
|
||
setBulkUpdateStep('select')
|
||
await loadData()
|
||
setTimeout(() => setErrorMessage(null), 8000)
|
||
} else {
|
||
// All updates failed
|
||
const errorMsg = data.errors && data.errors.length > 0
|
||
? data.errors.map((e: any) => `${e.deployment}/${e.container}: ${e.error}`).join('; ')
|
||
: data.error || data.message || 'Unknown error'
|
||
setErrorMessage(`❌ Failed to update images: ${errorMsg}`)
|
||
setTimeout(() => setErrorMessage(null), 10000)
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Error updating images:', error)
|
||
setErrorMessage(`❌ Failed to update images: ${error.message || 'Network error. Please check if the API server is running and you are connected to the cluster.'}`)
|
||
setTimeout(() => setErrorMessage(null), 10000)
|
||
} finally {
|
||
setIsUpdating(false)
|
||
}
|
||
}
|
||
|
||
const handleScaleDeployment = async (namespace: string, name: string, replicas: number) => {
|
||
setIsActioning(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
const response = await fetch(`${apiUrl}/k8s/deployments/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/scale`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ replicas }),
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (data.success) {
|
||
setErrorMessage(`✅ ${data.message}`)
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
setActionModal(null)
|
||
// Reload deployments to reflect the change
|
||
await loadData()
|
||
} else {
|
||
setErrorMessage(`❌ ${data.error || data.message || 'Failed to scale deployment'}`)
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage(`❌ Failed to scale deployment: ${error.message}`)
|
||
} finally {
|
||
setIsActioning(false)
|
||
}
|
||
}
|
||
|
||
const handleRestartDeployment = async (namespace: string, name: string) => {
|
||
setIsActioning(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
const response = await fetch(`${apiUrl}/k8s/deployments/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/restart`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (data.success) {
|
||
setErrorMessage(`✅ ${data.message}`)
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
setActionModal(null)
|
||
// Reload deployments to reflect the change
|
||
await loadData()
|
||
} else {
|
||
setErrorMessage(`❌ ${data.error || data.message || 'Failed to restart deployment'}`)
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage(`❌ Failed to restart deployment: ${error.message}`)
|
||
} finally {
|
||
setIsActioning(false)
|
||
}
|
||
}
|
||
|
||
const handleDeleteResource = async (resourceType: string, namespace: string, name: string) => {
|
||
setIsActioning(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
// If deleting a deployment, save its spec first for potential restore
|
||
if (resourceType === 'deployments') {
|
||
const deployment = deployments.find(d => d.name === name && d.namespace === namespace)
|
||
if (deployment) {
|
||
setDeletedDeployments(prev => [...prev, {
|
||
name: deployment.name,
|
||
namespace: deployment.namespace,
|
||
deletedAt: new Date().toISOString(),
|
||
spec: deployment
|
||
}])
|
||
}
|
||
}
|
||
|
||
const response = await fetch(`${apiUrl}/k8s/${resourceType}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (data.success) {
|
||
setErrorMessage(`✅ ${data.message}`)
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
setActionModal(null)
|
||
// Reload data to reflect the deletion
|
||
await loadData()
|
||
} else {
|
||
setErrorMessage(`❌ ${data.error || data.message || 'Failed to delete resource'}`)
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage(`❌ Failed to delete resource: ${error.message}`)
|
||
} finally {
|
||
setIsActioning(false)
|
||
}
|
||
}
|
||
|
||
// Handle pod selection toggle
|
||
const handlePodSelect = (podKey: string) => {
|
||
setSelectedPods(prev => {
|
||
const newSet = new Set(prev)
|
||
if (newSet.has(podKey)) {
|
||
newSet.delete(podKey)
|
||
} else {
|
||
newSet.add(podKey)
|
||
}
|
||
return newSet
|
||
})
|
||
}
|
||
|
||
// Handle select all pods
|
||
const handleSelectAllPods = () => {
|
||
if (selectedPods.size === pods.length) {
|
||
setSelectedPods(new Set())
|
||
} else {
|
||
setSelectedPods(new Set(pods.map(p => `${p.namespace}/${p.name}`)))
|
||
}
|
||
}
|
||
|
||
// Handle bulk delete pods
|
||
const handleBulkDeletePods = async () => {
|
||
if (selectedPods.size === 0) return
|
||
|
||
if (!confirm(`Are you sure you want to delete ${selectedPods.size} pod(s)? This action cannot be undone.`)) {
|
||
return
|
||
}
|
||
|
||
setIsDeletingPods(true)
|
||
setErrorMessage(null)
|
||
|
||
let successCount = 0
|
||
let failCount = 0
|
||
|
||
for (const podKey of selectedPods) {
|
||
const [namespace, name] = podKey.split('/')
|
||
try {
|
||
const response = await fetch(`${apiUrl}/k8s/pods/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
})
|
||
const data = await response.json()
|
||
if (data.success) {
|
||
successCount++
|
||
} else {
|
||
failCount++
|
||
}
|
||
} catch {
|
||
failCount++
|
||
}
|
||
}
|
||
|
||
setSelectedPods(new Set())
|
||
setIsDeletingPods(false)
|
||
|
||
if (failCount === 0) {
|
||
setErrorMessage(`✅ Successfully deleted ${successCount} pod(s)`)
|
||
} else {
|
||
setErrorMessage(`⚠️ Deleted ${successCount} pod(s), failed to delete ${failCount} pod(s)`)
|
||
}
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
await loadData()
|
||
}
|
||
|
||
// Handle restore deleted deployment
|
||
const handleRestoreDeployment = async (deletedDeployment: { name: string; namespace: string; deletedAt: string; spec: any }) => {
|
||
if (!confirm(`Are you sure you want to restore deployment "${deletedDeployment.name}" in namespace "${deletedDeployment.namespace}"?`)) {
|
||
return
|
||
}
|
||
|
||
setIsActioning(true)
|
||
setErrorMessage(null)
|
||
|
||
try {
|
||
// Create deployment from saved spec
|
||
const response = await fetch(`${apiUrl}/k8s/deployments`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
namespace: deletedDeployment.namespace,
|
||
name: deletedDeployment.name,
|
||
spec: deletedDeployment.spec
|
||
}),
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (data.success) {
|
||
// Remove from deleted deployments list
|
||
setDeletedDeployments(prev => prev.filter(d =>
|
||
!(d.name === deletedDeployment.name && d.namespace === deletedDeployment.namespace && d.deletedAt === deletedDeployment.deletedAt)
|
||
))
|
||
setErrorMessage(`✅ Successfully restored deployment "${deletedDeployment.name}"`)
|
||
setTimeout(() => setErrorMessage(null), 5000)
|
||
await loadData()
|
||
} else {
|
||
setErrorMessage(`❌ Failed to restore deployment: ${data.error || data.message}`)
|
||
}
|
||
} catch (error: any) {
|
||
setErrorMessage(`❌ Failed to restore deployment: ${error.message}`)
|
||
} finally {
|
||
setIsActioning(false)
|
||
}
|
||
}
|
||
|
||
// Handle clear deleted deployment from history
|
||
const handleClearDeletedDeployment = (deletedDeployment: { name: string; namespace: string; deletedAt: string; spec: any }) => {
|
||
setDeletedDeployments(prev => prev.filter(d =>
|
||
!(d.name === deletedDeployment.name && d.namespace === deletedDeployment.namespace && d.deletedAt === deletedDeployment.deletedAt)
|
||
))
|
||
}
|
||
|
||
// Auto-connect on component mount (using ServiceAccount)
|
||
useEffect(() => {
|
||
if (apiUrl && !isConnected) {
|
||
loadNamespaces(true);
|
||
}
|
||
}, [apiUrl]);
|
||
|
||
// Get docker image tag from portal deployment for build version
|
||
const portalDeployment = deployments.find(d => d.name.toLowerCase().includes('portal'))
|
||
const dockerImageTag = portalDeployment && portalDeployment.containers?.length > 0
|
||
? portalDeployment.containers[0].image.split(':')[1] || 'latest'
|
||
: (deployments.length > 0 && deployments[0].containers?.length > 0
|
||
? deployments[0].containers[0].image.split(':')[1] || 'latest'
|
||
: 'latest')
|
||
|
||
// ─── Helper: time ago ───
|
||
const timeAgo = (dateStr: string) => {
|
||
if (!dateStr) return '-'
|
||
const now = new Date()
|
||
const date = new Date(dateStr)
|
||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||
if (seconds < 0) return 'just now'
|
||
if (seconds < 60) return `${seconds}s ago`
|
||
const minutes = Math.floor(seconds / 60)
|
||
if (minutes < 60) return `${minutes}m ago`
|
||
const hours = Math.floor(minutes / 60)
|
||
if (hours < 24) return `${hours}h ago`
|
||
const days = Math.floor(hours / 24)
|
||
return `${days}d ago`
|
||
}
|
||
|
||
// ─── Fetch audit logs for overview ───
|
||
useEffect(() => {
|
||
if (activeResourceTab === 'overview' && isConnected && apiUrl) {
|
||
fetch(`${apiUrl}/audit/logs?limit=10`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.success) setAuditLogs(data.logs || [])
|
||
})
|
||
.catch(err => console.warn('Failed to fetch audit logs:', err))
|
||
}
|
||
}, [activeResourceTab, isConnected, apiUrl])
|
||
|
||
// ─── Fetch pod for Describe modal ───
|
||
useEffect(() => {
|
||
if (!describePod || !apiUrl) {
|
||
setDescribePodContent(null)
|
||
setDescribePodError(null)
|
||
return
|
||
}
|
||
let cancelled = false
|
||
setDescribePodLoading(true)
|
||
setDescribePodError(null)
|
||
setDescribePodContent(null)
|
||
fetch(`${apiUrl}/k8s/pods/${encodeURIComponent(describePod.namespace)}/${encodeURIComponent(describePod.name)}`, {
|
||
headers: getAuthHeaders(),
|
||
})
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (cancelled) return
|
||
if (data.success && data.pod) {
|
||
setDescribePodContent(JSON.stringify(data.pod, null, 2))
|
||
} else {
|
||
setDescribePodError(data.message || 'Failed to load pod')
|
||
}
|
||
})
|
||
.catch(err => {
|
||
if (!cancelled) setDescribePodError(err.message || 'Failed to fetch pod')
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setDescribePodLoading(false)
|
||
})
|
||
return () => { cancelled = true }
|
||
}, [describePod, apiUrl])
|
||
|
||
// ─── Global search keyboard shortcut (Cmd+K / Ctrl+K) ───
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||
e.preventDefault()
|
||
setShowSearchModal(true)
|
||
setSearchQuery('')
|
||
}
|
||
if (e.key === 'Escape' && showSearchModal) {
|
||
setShowSearchModal(false)
|
||
}
|
||
}
|
||
document.addEventListener('keydown', handleKeyDown)
|
||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||
}, [showSearchModal])
|
||
|
||
// ─── Search results computation ───
|
||
const searchResults = useMemo(() => {
|
||
if (!searchQuery.trim()) return []
|
||
const q = searchQuery.toLowerCase()
|
||
const results: Array<{ type: string; name: string; namespace: string; tabKey: string; resourceType: string }> = []
|
||
deployments.filter(d => d.name.toLowerCase().includes(q)).forEach(d =>
|
||
results.push({ type: 'Deployment', name: d.name, namespace: d.namespace, tabKey: 'deployments', resourceType: 'deployment' }))
|
||
pods.filter(p => p.name.toLowerCase().includes(q)).forEach(p =>
|
||
results.push({ type: 'Pod', name: p.name, namespace: p.namespace, tabKey: 'pods', resourceType: 'pod' }))
|
||
services.filter(s => s.name.toLowerCase().includes(q)).forEach(s =>
|
||
results.push({ type: 'Service', name: s.name, namespace: s.namespace, tabKey: 'services', resourceType: 'service' }))
|
||
configmaps.filter(c => c.name?.toLowerCase().includes(q)).forEach(c =>
|
||
results.push({ type: 'ConfigMap', name: c.name, namespace: c.namespace, tabKey: 'configmaps', resourceType: 'configmap' }))
|
||
secrets.filter(s => s.name?.toLowerCase().includes(q)).forEach(s =>
|
||
results.push({ type: 'Secret', name: s.name, namespace: s.namespace, tabKey: 'secrets', resourceType: 'secret' }))
|
||
cronjobs.filter(c => c.name.toLowerCase().includes(q)).forEach(c =>
|
||
results.push({ type: 'CronJob', name: c.name, namespace: c.namespace, tabKey: 'cronjobs', resourceType: 'cronjob' }))
|
||
jobs.filter(j => j.name.toLowerCase().includes(q)).forEach(j =>
|
||
results.push({ type: 'Job', name: j.name, namespace: j.namespace, tabKey: 'jobs', resourceType: 'job' }))
|
||
ingresses.filter(i => i.name.toLowerCase().includes(q)).forEach(i =>
|
||
results.push({ type: 'Ingress', name: i.name, namespace: i.namespace, tabKey: 'ingresses', resourceType: 'ingress' }))
|
||
pvcs.filter(p => p.name.toLowerCase().includes(q)).forEach(p =>
|
||
results.push({ type: 'PVC', name: p.name, namespace: p.namespace, tabKey: 'pvcs', resourceType: 'pvc' }))
|
||
// Nodes
|
||
nodes.filter((n: any) => (n.name || n.metadata?.name || '').toLowerCase().includes(q)).forEach((n: any) =>
|
||
results.push({ type: 'Node', name: n.name || n.metadata?.name, namespace: '', tabKey: 'overview', resourceType: 'node' }))
|
||
// Events
|
||
events.filter((e: any) => (e.message || '').toLowerCase().includes(q) || (e.reason || '').toLowerCase().includes(q) || (e.involvedObject?.name || '').toLowerCase().includes(q)).forEach((e: any) =>
|
||
results.push({ type: 'Event', name: `${e.involvedObject?.kind || ''}/${e.involvedObject?.name || ''} — ${e.reason || ''}`, namespace: e.namespace || '', tabKey: 'events', resourceType: 'event' }))
|
||
// Databases from localStorage (connected database services)
|
||
try {
|
||
const savedDbs = localStorage.getItem('database_connections')
|
||
if (savedDbs) {
|
||
const dbConns = JSON.parse(savedDbs) as Array<{ name: string; type: string; host?: string; namespace?: string }>
|
||
dbConns.filter(db => (db.name || '').toLowerCase().includes(q) || (db.type || '').toLowerCase().includes(q) || (db.host || '').toLowerCase().includes(q)).forEach(db =>
|
||
results.push({ type: 'Database', name: `${db.name} (${db.type})`, namespace: db.namespace || '', tabKey: 'databases', resourceType: 'database' }))
|
||
}
|
||
} catch {}
|
||
return results.slice(0, 50)
|
||
}, [searchQuery, deployments, pods, services, configmaps, secrets, cronjobs, jobs, ingresses, pvcs, nodes, events])
|
||
|
||
// ─── Notifications computation ───
|
||
const notifications = useMemo(() => {
|
||
const notifs: Array<{ type: 'error' | 'warning'; message: string; resource: string; namespace: string; timestamp: string }> = []
|
||
// Pods with CrashLoopBackOff or OOMKilled
|
||
pods.forEach(pod => {
|
||
if (pod.issues && Array.isArray(pod.issues)) {
|
||
pod.issues.forEach((issue: any) => {
|
||
if (issue.reason === 'CrashLoopBackOff' || issue.reason === 'OOMKilled') {
|
||
notifs.push({ type: 'error', message: `${issue.reason}: ${issue.message || pod.name}`, resource: pod.name, namespace: pod.namespace, timestamp: pod.startTime || '' })
|
||
}
|
||
})
|
||
}
|
||
})
|
||
// Pods with any issues (hasIssues flag from API)
|
||
pods.forEach(pod => {
|
||
if (pod.hasIssues && (!pod.issues || pod.issues.length === 0)) {
|
||
notifs.push({ type: 'warning', message: `Pod ${pod.name} has issues (status: ${pod.status})`, resource: pod.name, namespace: pod.namespace, timestamp: pod.startTime || '' })
|
||
}
|
||
})
|
||
// Pods not in Running/Succeeded state
|
||
pods.forEach(pod => {
|
||
if (pod.status && pod.status !== 'Running' && pod.status !== 'Succeeded') {
|
||
// Avoid duplicate if already caught by issues above
|
||
const alreadyNotified = notifs.some(n => n.resource === pod.name && n.namespace === pod.namespace)
|
||
if (!alreadyNotified) {
|
||
notifs.push({ type: 'warning', message: `Pod ${pod.name} is ${pod.status}`, resource: pod.name, namespace: pod.namespace, timestamp: pod.startTime || pod.creationTimestamp || '' })
|
||
}
|
||
}
|
||
})
|
||
// Deployments with Stalled / ReplicaFailure / Unavailable / Degraded
|
||
deployments.forEach(dep => {
|
||
if (dep.deploymentStatus === 'Stalled' || dep.deploymentStatus === 'ReplicaFailure' || dep.deploymentStatus === 'Unavailable' || dep.deploymentStatus === 'Degraded') {
|
||
notifs.push({ type: 'error', message: `Deployment ${dep.name} is ${dep.deploymentStatus}`, resource: dep.name, namespace: dep.namespace, timestamp: dep.creationTimestamp })
|
||
}
|
||
})
|
||
// Warning events — check both lastTimestamp and eventTime, use 2-hour window
|
||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000)
|
||
events.forEach((event: any) => {
|
||
if (event.type === 'Warning') {
|
||
const eventTs = event.lastTimestamp || event.eventTime || event.firstTimestamp
|
||
if (eventTs && new Date(eventTs) > twoHoursAgo) {
|
||
notifs.push({ type: 'warning', message: `${event.reason}: ${(event.message || '').slice(0, 80)}`, resource: `${event.involvedObject?.kind || ''}/${event.involvedObject?.name || ''}`, namespace: event.namespace || '', timestamp: eventTs })
|
||
}
|
||
}
|
||
})
|
||
// Pods with high restart count (API returns "restarts" field, not "restartCount")
|
||
pods.forEach(pod => {
|
||
const restarts = (pod as any).restarts ?? (pod as any).restartCount ?? 0
|
||
if (restarts > 5) {
|
||
// Avoid duplicate
|
||
const alreadyNotified = notifs.some(n => n.resource === pod.name && n.namespace === pod.namespace && n.message.includes('restarts'))
|
||
if (!alreadyNotified) {
|
||
notifs.push({ type: 'warning', message: `Pod ${pod.name} has ${restarts} restarts`, resource: pod.name, namespace: pod.namespace, timestamp: pod.startTime || '' })
|
||
}
|
||
}
|
||
})
|
||
return notifs.slice(0, 50)
|
||
}, [pods, deployments, events])
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-100">
|
||
{/* Left Sidebar - Fixed Dark Theme */}
|
||
<div className="w-52 bg-[#1e2a3a] flex flex-col fixed left-0 top-0 bottom-0 z-40">
|
||
{/* Logo/Brand Section */}
|
||
<div className="p-4 border-b border-[#2d3d4f]">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||
<Server className="w-5 h-5 text-white" />
|
||
</div>
|
||
<span className="text-white font-semibold text-lg">Klarity</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Project/Namespace Selector */}
|
||
<div className="p-4 border-b border-[#2d3d4f]">
|
||
<div className="text-[10px] text-gray-400 uppercase tracking-wider mb-1">NAMESPACE</div>
|
||
<div className="text-white text-sm font-medium truncate px-2 py-1.5">
|
||
{selectedNamespace || 'Connecting...'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Navigation Menu */}
|
||
<nav className="flex-1 overflow-y-auto py-4">
|
||
{/* Dashboard Overview */}
|
||
<div className="px-3 mb-2">
|
||
<button
|
||
onClick={() => setActiveResourceTab('overview')}
|
||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||
activeResourceTab === 'overview' ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-[#2d3d4f] hover:text-white'
|
||
}`}
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||
</svg>
|
||
Dashboard
|
||
</button>
|
||
</div>
|
||
|
||
{/* Resources Section */}
|
||
<div className="px-3 mb-2">
|
||
<button
|
||
onClick={() => {
|
||
const el = document.getElementById('resources-menu')
|
||
if (el) el.classList.toggle('hidden')
|
||
}}
|
||
className="w-full flex items-center justify-between px-3 py-2 text-gray-400 hover:text-white text-xs font-semibold uppercase tracking-wider"
|
||
>
|
||
<span>Resources</span>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div id="resources-menu" className="mt-1 space-y-1">
|
||
<button onClick={() => setActiveResourceTab('deployments')} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'deployments' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<span className="flex items-center gap-3">
|
||
<Server className="w-4 h-4" />
|
||
Deployments
|
||
</span>
|
||
<span className="text-xs bg-[#3d4d5f] px-2 py-0.5 rounded">{deployments.length}</span>
|
||
</button>
|
||
<button onClick={() => setActiveResourceTab('pods')} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'pods' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<span className="flex items-center gap-3">
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><circle cx="15.5" cy="8.5" r="1.5"/><circle cx="8.5" cy="15.5" r="1.5"/><circle cx="15.5" cy="15.5" r="1.5"/></svg>
|
||
Pods
|
||
</span>
|
||
<span className="text-xs bg-[#3d4d5f] px-2 py-0.5 rounded">{pods.length}</span>
|
||
</button>
|
||
<button onClick={() => setActiveResourceTab('services')} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'services' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<span className="flex items-center gap-3">
|
||
<Network className="w-4 h-4" />
|
||
Services
|
||
</span>
|
||
<span className="text-xs bg-[#3d4d5f] px-2 py-0.5 rounded">{services.length}</span>
|
||
</button>
|
||
<button onClick={() => setActiveResourceTab('configmaps')} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'configmaps' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<span className="flex items-center gap-3">
|
||
<FileText className="w-4 h-4" />
|
||
ConfigMaps
|
||
</span>
|
||
<span className="text-xs bg-[#3d4d5f] px-2 py-0.5 rounded">{configmaps.length}</span>
|
||
</button>
|
||
<button onClick={() => setActiveResourceTab('secrets')} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'secrets' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<span className="flex items-center gap-3">
|
||
<Lock className="w-4 h-4" />
|
||
Secrets
|
||
</span>
|
||
<span className="text-xs bg-[#3d4d5f] px-2 py-0.5 rounded">{secrets.length}</span>
|
||
</button>
|
||
<button onClick={() => setActiveResourceTab('cronjobs')} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'cronjobs' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<span className="flex items-center gap-3">
|
||
<RotateCw className="w-4 h-4" />
|
||
CronJobs
|
||
</span>
|
||
<span className="text-xs bg-[#3d4d5f] px-2 py-0.5 rounded">{cronjobs.length}</span>
|
||
</button>
|
||
<button onClick={() => setActiveResourceTab('jobs')} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'jobs' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<span className="flex items-center gap-3">
|
||
<CheckCircle className="w-4 h-4" />
|
||
Jobs
|
||
</span>
|
||
<span className="text-xs bg-[#3d4d5f] px-2 py-0.5 rounded">{jobs.length}</span>
|
||
</button>
|
||
<button onClick={() => setActiveResourceTab('events')} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'events' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<span className="flex items-center gap-3">
|
||
<AlertTriangle className="w-4 h-4" />
|
||
Events
|
||
</span>
|
||
<span className="text-xs bg-[#3d4d5f] px-2 py-0.5 rounded">{events.length}</span>
|
||
</button>
|
||
<button onClick={() => setActiveResourceTab('ingresses')} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'ingresses' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<span className="flex items-center gap-3">
|
||
<Network className="w-4 h-4" />
|
||
Ingresses
|
||
</span>
|
||
<span className="text-xs bg-[#3d4d5f] px-2 py-0.5 rounded">{ingresses.length}</span>
|
||
</button>
|
||
<button onClick={() => setActiveResourceTab('pvcs')} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'pvcs' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<span className="flex items-center gap-3">
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" /></svg>
|
||
PVCs
|
||
</span>
|
||
<span className="text-xs bg-[#3d4d5f] px-2 py-0.5 rounded">{pvcs.length}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Observability Section */}
|
||
<div className="px-3 mb-2">
|
||
<button
|
||
onClick={() => {
|
||
const el = document.getElementById('observability-menu')
|
||
if (el) el.classList.toggle('hidden')
|
||
}}
|
||
className="w-full flex items-center justify-between px-3 py-2 text-gray-400 hover:text-white text-xs font-semibold uppercase tracking-wider"
|
||
>
|
||
<span>Observability</span>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div id="observability-menu" className="mt-1 space-y-1">
|
||
<button onClick={() => setActiveResourceTab('flow-tracing')} className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'flow-tracing' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<Network className="w-4 h-4" />
|
||
Flow Tracing
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Data Section - only when database feature enabled */}
|
||
{enableDatabase && (
|
||
<div className="px-3 mb-2">
|
||
<button
|
||
onClick={() => {
|
||
const el = document.getElementById('data-menu')
|
||
if (el) el.classList.toggle('hidden')
|
||
}}
|
||
className="w-full flex items-center justify-between px-3 py-2 text-gray-400 hover:text-white text-xs font-semibold uppercase tracking-wider"
|
||
>
|
||
<span>Data</span>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div id="data-menu" className="mt-1 space-y-1">
|
||
{/* Databases sidebar item hidden */}
|
||
{enableMongoDBWizard && (
|
||
<button onClick={() => setActiveResourceTab('mongodb-wizard')} className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'mongodb-wizard' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3C7 3 4 7.5 4 12c0 3.5 1.5 6.5 4 8.5 0-3 1-5 4-6-3 1-4 4-4 4s1-4 4-5.5c-2 0-3.5.5-3.5.5S10 9 12 9s3.5 3.5 3.5 3.5S14 12 12 12c3 1.5 4 4.5 4 6 2.5-2 4-5 4-8.5C20 7.5 17 3 12 3z"/></svg>
|
||
MongoDB Compass
|
||
</button>
|
||
)}
|
||
{enableAdminerWizard && (
|
||
<button onClick={() => setActiveResourceTab('adminer-wizard')} className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'adminer-wizard' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||
Adminer
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tools Section */}
|
||
<div className="px-3 mb-2">
|
||
<button
|
||
onClick={() => {
|
||
const el = document.getElementById('tools-menu')
|
||
if (el) el.classList.toggle('hidden')
|
||
}}
|
||
className="w-full flex items-center justify-between px-3 py-2 text-gray-400 hover:text-white text-xs font-semibold uppercase tracking-wider"
|
||
>
|
||
<span>Tools</span>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div id="tools-menu" className="mt-1 space-y-1">
|
||
{enableImageChangeReport && (
|
||
<button onClick={() => setActiveResourceTab('image-change-report')} className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'image-change-report' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<BarChart2 className="w-4 h-4" />
|
||
Image change report
|
||
</button>
|
||
)}
|
||
<button onClick={() => setActiveResourceTab('download-images')} className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all ${activeResourceTab === 'download-images' ? 'bg-[#2d3d4f] text-white' : 'text-gray-400 hover:bg-[#2d3d4f] hover:text-white'}`}>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||
</svg>
|
||
Download Images
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</nav>
|
||
|
||
{/* Bottom Section */}
|
||
<div className="p-4 border-t border-[#2d3d4f]">
|
||
<div className="text-[10px] text-gray-500">Build version: <span className="text-gray-400 font-mono">{dockerImageTag}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Content Area - Full width with left margin for fixed sidebar */}
|
||
<div className="ml-52 min-h-screen">
|
||
{/* Connection Banner - Only when not connected */}
|
||
{!isConnected && (
|
||
<div className="p-6">
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<div className="flex items-center gap-2 text-blue-700">
|
||
<Loader2 className="w-5 h-5 animate-spin" />
|
||
<span className="font-medium">Connecting to Kubernetes cluster via ServiceAccount...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Content */}
|
||
{isConnected && (
|
||
<div className="min-h-screen">
|
||
{/* Header Banner - no overflow-hidden so notification dropdown is not clipped */}
|
||
<div className="relative bg-gradient-to-r from-gray-700 to-gray-800 text-white">
|
||
<div className="absolute inset-0 opacity-10">
|
||
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||
<defs>
|
||
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="white" strokeWidth="0.5"/>
|
||
</pattern>
|
||
</defs>
|
||
<rect width="100" height="100" fill="url(#grid)" />
|
||
</svg>
|
||
</div>
|
||
<div className="relative px-4 sm:px-6 py-4 sm:py-6 flex items-start justify-between">
|
||
<div>
|
||
<h1 className="text-xl sm:text-2xl font-light mb-1">Welcome to Klarity</h1>
|
||
<p className="text-gray-300 text-xs sm:text-sm">Experience seamless Kubernetes management with powerful observability and control.</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 z-10">
|
||
{/* Search Button */}
|
||
<button onClick={() => { setShowSearchModal(true); setSearchQuery('') }}
|
||
className="flex items-center gap-2 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-sm text-gray-300 transition-colors">
|
||
<Search className="w-4 h-4" />
|
||
<span className="hidden sm:inline">Search</span>
|
||
<kbd className="hidden sm:inline px-1.5 py-0.5 text-[10px] bg-white/10 rounded font-mono">⌘K</kbd>
|
||
</button>
|
||
{/* Notification Bell */}
|
||
<div className="relative">
|
||
<button onClick={() => setShowNotificationPanel(!showNotificationPanel)}
|
||
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors">
|
||
<Bell className="w-5 h-5 text-white" />
|
||
{notifications.length > 0 && (
|
||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-[10px] rounded-full flex items-center justify-center font-bold">
|
||
{notifications.length > 9 ? '9+' : notifications.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{showNotificationPanel && (
|
||
<div className="absolute right-0 top-full mt-2 w-96 bg-white rounded-lg shadow-xl border border-gray-200 z-50 max-h-[400px] overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-gray-900">Notifications ({notifications.length})</h3>
|
||
<button onClick={() => setShowNotificationPanel(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
||
</div>
|
||
<div className="overflow-y-auto max-h-[340px] divide-y divide-gray-100">
|
||
{notifications.length === 0 ? (
|
||
<div className="p-6 text-center text-sm text-gray-400">
|
||
<CheckCircle className="w-8 h-8 mx-auto mb-2 text-green-400" />
|
||
All clear! No issues detected.
|
||
</div>
|
||
) : notifications.map((notif, idx) => {
|
||
const parts = (notif.resource || '').split('/')
|
||
const kind = parts.length > 1 ? parts[0] : 'Pod'
|
||
const name = parts.length > 1 ? parts[1] : notif.resource
|
||
const tabMap: Record<string, string> = { Pod: 'pods', Deployment: 'deployments', Service: 'services' }
|
||
const typeMap: Record<string, 'pod' | 'deployment' | 'service'> = { Pod: 'pod', Deployment: 'deployment', Service: 'service' }
|
||
const tab = tabMap[kind] || 'pods'
|
||
const resType = typeMap[kind] || 'pod'
|
||
return (
|
||
<div
|
||
key={idx}
|
||
className="px-4 py-3 hover:bg-gray-50 cursor-pointer"
|
||
onClick={() => {
|
||
setShowNotificationPanel(false)
|
||
setActiveResourceTab(tab as any)
|
||
if (name) setSelectedResource({ type: resType, namespace: notif.namespace || selectedNamespace, name })
|
||
}}
|
||
>
|
||
<div className="flex items-start gap-2">
|
||
<AlertTriangle className={`w-4 h-4 mt-0.5 flex-shrink-0 ${notif.type === 'error' ? 'text-red-500' : 'text-amber-500'}`} />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm text-gray-900 truncate">{notif.message}</p>
|
||
<p className="text-xs text-gray-500 mt-0.5">{notif.resource} · {timeAgo(notif.timestamp)}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="absolute right-0 top-0 h-full w-1/4 opacity-20">
|
||
<svg viewBox="0 0 200 200" className="h-full w-full">
|
||
<circle cx="100" cy="100" r="80" fill="none" stroke="white" strokeWidth="0.5"/>
|
||
<circle cx="100" cy="100" r="60" fill="none" stroke="white" strokeWidth="0.5"/>
|
||
<circle cx="100" cy="100" r="40" fill="none" stroke="white" strokeWidth="0.5"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tabs Navigation */}
|
||
<div className="bg-white border-b border-gray-200 px-4 sm:px-6 overflow-x-auto">
|
||
<div className="flex gap-4 sm:gap-6 min-w-max">
|
||
<button
|
||
onClick={() => setActiveResourceTab('overview')}
|
||
className={`py-3 text-sm font-medium border-b-2 transition-colors ${
|
||
activeResourceTab === 'overview' || activeResourceTab === 'deployments' || activeResourceTab === 'pods' || activeResourceTab === 'services' || activeResourceTab === 'configmaps' || activeResourceTab === 'secrets' || activeResourceTab === 'cronjobs' || activeResourceTab === 'jobs' || activeResourceTab === 'events' || activeResourceTab === 'ingresses' || activeResourceTab === 'pvcs'
|
||
? 'border-blue-600 text-blue-600'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
Overview
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveResourceTab('flow-tracing')}
|
||
className={`py-3 text-sm font-medium border-b-2 transition-colors ${
|
||
activeResourceTab === 'flow-tracing'
|
||
? 'border-blue-600 text-blue-600'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
Flow Tracing
|
||
</button>
|
||
{/* Databases tab hidden */}
|
||
{enableMongoDBWizard && (
|
||
<button
|
||
onClick={() => setActiveResourceTab('mongodb-wizard')}
|
||
className={`py-3 text-sm font-medium border-b-2 transition-colors ${
|
||
activeResourceTab === 'mongodb-wizard'
|
||
? 'border-green-600 text-green-600'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
MongoDB Compass
|
||
</button>
|
||
)}
|
||
{enableAdminerWizard && (
|
||
<button
|
||
onClick={() => setActiveResourceTab('adminer-wizard')}
|
||
className={`py-3 text-sm font-medium border-b-2 transition-colors ${
|
||
activeResourceTab === 'adminer-wizard'
|
||
? 'border-blue-600 text-blue-600'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
Adminer
|
||
</button>
|
||
)}
|
||
{enableImageChangeReport && (
|
||
<button
|
||
onClick={() => setActiveResourceTab('image-change-report')}
|
||
className={`py-3 text-sm font-medium border-b-2 transition-colors ${
|
||
activeResourceTab === 'image-change-report'
|
||
? 'border-blue-600 text-blue-600'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
Image change report
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content Area */}
|
||
<div className="p-4 sm:p-6">
|
||
{/* Quick Stats Cards + Resource Summary - hidden on Flow Tracing tab and all DB tabs */}
|
||
{activeResourceTab !== 'flow-tracing' &&
|
||
activeResourceTab !== 'databases' &&
|
||
activeResourceTab !== 'mongodb-wizard' &&
|
||
activeResourceTab !== 'adminer-wizard' && (
|
||
<>
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">Cluster Resources</h2>
|
||
<span className="px-2.5 py-1 bg-emerald-100 text-emerald-700 text-xs font-medium rounded-full flex items-center gap-1">
|
||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full"></span>
|
||
Connected
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={autoRefresh}
|
||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<span className="text-sm text-gray-600">Auto-refresh</span>
|
||
</label>
|
||
{autoRefresh && (
|
||
<select
|
||
value={refreshInterval}
|
||
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||
className="px-2 py-1 text-sm border border-gray-300 rounded text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||
>
|
||
<option value={5}>5s</option>
|
||
<option value={10}>10s</option>
|
||
<option value={15}>15s</option>
|
||
<option value={30}>30s</option>
|
||
<option value={60}>60s</option>
|
||
</select>
|
||
)}
|
||
<button
|
||
onClick={() => loadData()}
|
||
disabled={isLoading}
|
||
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Compact Resource Summary Cards */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-3 mb-4 sm:mb-6">
|
||
<div onClick={() => setActiveResourceTab('deployments')} className={`bg-white rounded-lg border p-3 cursor-pointer transition-all hover:shadow ${activeResourceTab === 'deployments' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}`}>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Server className="w-4 h-4 text-blue-600" />
|
||
<span className="text-xs font-medium text-gray-600">Deployments</span>
|
||
</div>
|
||
<div className="text-xl font-bold text-gray-900 text-center">{deployments.length}</div>
|
||
</div>
|
||
<div onClick={() => setActiveResourceTab('pods')} className={`bg-white rounded-lg border p-3 cursor-pointer transition-all hover:shadow ${activeResourceTab === 'pods' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}`}>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<svg className="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||
<span className="text-xs font-medium text-gray-600">Pods</span>
|
||
</div>
|
||
<div className="text-xl font-bold text-gray-900 text-center">{pods.length}</div>
|
||
</div>
|
||
<div onClick={() => setActiveResourceTab('services')} className={`bg-white rounded-lg border p-3 cursor-pointer transition-all hover:shadow ${activeResourceTab === 'services' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}`}>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Network className="w-4 h-4 text-purple-600" />
|
||
<span className="text-xs font-medium text-gray-600">Services</span>
|
||
</div>
|
||
<div className="text-xl font-bold text-gray-900 text-center">{services.length}</div>
|
||
</div>
|
||
<div onClick={() => setActiveResourceTab('cronjobs')} className={`bg-white rounded-lg border p-3 cursor-pointer transition-all hover:shadow ${activeResourceTab === 'cronjobs' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}`}>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<RotateCw className="w-4 h-4 text-orange-600" />
|
||
<span className="text-xs font-medium text-gray-600">CronJobs</span>
|
||
</div>
|
||
<div className="text-xl font-bold text-gray-900 text-center">{cronjobs.length}</div>
|
||
</div>
|
||
<div onClick={() => setActiveResourceTab('jobs')} className={`bg-white rounded-lg border p-3 cursor-pointer transition-all hover:shadow ${activeResourceTab === 'jobs' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}`}>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<CheckCircle className="w-4 h-4 text-teal-600" />
|
||
<span className="text-xs font-medium text-gray-600">Jobs</span>
|
||
</div>
|
||
<div className="text-xl font-bold text-gray-900 text-center">{jobs.length}</div>
|
||
</div>
|
||
<div onClick={() => setActiveResourceTab('events')} className={`bg-white rounded-lg border p-3 cursor-pointer transition-all hover:shadow ${activeResourceTab === 'events' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}`}>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<AlertTriangle className="w-4 h-4 text-amber-600" />
|
||
<span className="text-xs font-medium text-gray-600">Events</span>
|
||
</div>
|
||
<div className="text-xl font-bold text-gray-900 text-center">{events.length}</div>
|
||
</div>
|
||
<div onClick={() => setActiveResourceTab('ingresses')} className={`bg-white rounded-lg border p-3 cursor-pointer transition-all hover:shadow ${activeResourceTab === 'ingresses' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}`}>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Network className="w-4 h-4 text-indigo-600" />
|
||
<span className="text-xs font-medium text-gray-600">Ingresses</span>
|
||
</div>
|
||
<div className="text-xl font-bold text-gray-900 text-center">{ingresses.length}</div>
|
||
</div>
|
||
<div onClick={() => setActiveResourceTab('pvcs')} className={`bg-white rounded-lg border p-3 cursor-pointer transition-all hover:shadow ${activeResourceTab === 'pvcs' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}`}>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<svg className="w-4 h-4 text-pink-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" /></svg>
|
||
<span className="text-xs font-medium text-gray-600">PVCs</span>
|
||
</div>
|
||
<div className="text-xl font-bold text-gray-900 text-center">{pvcs.length}</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ═══ OVERVIEW TAB ═══ */}
|
||
{activeResourceTab === 'overview' && (
|
||
<div className="space-y-6">
|
||
{/* Health Summary Cards - clickable, numbers centered */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<div onClick={() => setActiveResourceTab('deployments')} className="bg-white rounded-xl border border-gray-200 p-5 cursor-pointer transition-all hover:shadow-md hover:border-gray-300">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Server className="w-5 h-5 text-blue-600" />
|
||
<span className="text-sm font-medium text-gray-600">Deployments</span>
|
||
</div>
|
||
<div className="text-3xl font-bold text-gray-900 text-center">{deployments.length}</div>
|
||
<div className="flex items-center justify-center gap-3 mt-2 flex-wrap">
|
||
<span className="text-xs px-2 py-0.5 bg-green-100 text-green-700 rounded-full">{deployments.filter(d => d.deploymentStatus === 'Ready').length} healthy</span>
|
||
{deployments.filter(d => d.hasIssues).length > 0 && <span className="text-xs px-2 py-0.5 bg-red-100 text-red-700 rounded-full">{deployments.filter(d => d.hasIssues).length} issues</span>}
|
||
</div>
|
||
</div>
|
||
<div onClick={() => setActiveResourceTab('pods')} className="bg-white rounded-xl border border-gray-200 p-5 cursor-pointer transition-all hover:shadow-md hover:border-gray-300">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<CheckCircle className="w-5 h-5 text-emerald-600" />
|
||
<span className="text-sm font-medium text-gray-600">Pods</span>
|
||
</div>
|
||
<div className="text-3xl font-bold text-gray-900 text-center">
|
||
<span className="text-emerald-600">{pods.filter((p: any) => p.status === 'Running' && p.ready !== false).length}</span>
|
||
<span className="text-lg text-gray-400 font-normal">/{pods.length}</span>
|
||
</div>
|
||
<div className="text-xs text-gray-500 mt-2 text-center">Running</div>
|
||
</div>
|
||
<div onClick={() => setActiveResourceTab('services')} className="bg-white rounded-xl border border-gray-200 p-5 cursor-pointer transition-all hover:shadow-md hover:border-gray-300">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Network className="w-5 h-5 text-purple-600" />
|
||
<span className="text-sm font-medium text-gray-600">Services</span>
|
||
</div>
|
||
<div className="text-3xl font-bold text-gray-900 text-center">{services.length}</div>
|
||
<div className="text-xs text-gray-500 mt-2 text-center">Active in namespace</div>
|
||
</div>
|
||
<div onClick={() => setActiveResourceTab('events')} className="bg-white rounded-xl border border-gray-200 p-5 cursor-pointer transition-all hover:shadow-md hover:border-gray-300">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||
<span className="text-sm font-medium text-gray-600">Warning Events</span>
|
||
</div>
|
||
<div className="text-3xl font-bold text-gray-900 text-center">{events.filter((e: any) => e.type === 'Warning').length}</div>
|
||
<div className="text-xs text-gray-500 mt-2 text-center">From cluster events</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Deployment Status Distribution Bar */}
|
||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Deployment Health</h3>
|
||
{(() => {
|
||
const total = deployments.length || 1
|
||
const ready = deployments.filter(d => d.deploymentStatus === 'Ready').length
|
||
const progressing = deployments.filter(d => d.deploymentStatus === 'Progressing' || d.deploymentStatus === 'Updating').length
|
||
const stalled = deployments.filter(d => d.deploymentStatus === 'Stalled' || d.deploymentStatus === 'Degraded').length
|
||
const failed = deployments.filter(d => d.deploymentStatus === 'Unavailable' || d.deploymentStatus === 'ReplicaFailure').length
|
||
const other = total - ready - progressing - stalled - failed
|
||
return (
|
||
<div>
|
||
<div className="flex h-3 rounded-full overflow-hidden bg-gray-100">
|
||
{ready > 0 && <div className="bg-green-500 transition-all" style={{ width: `${(ready / total) * 100}%` }} title={`Ready: ${ready}`} />}
|
||
{progressing > 0 && <div className="bg-blue-500 transition-all" style={{ width: `${(progressing / total) * 100}%` }} title={`Progressing: ${progressing}`} />}
|
||
{stalled > 0 && <div className="bg-orange-500 transition-all" style={{ width: `${(stalled / total) * 100}%` }} title={`Stalled: ${stalled}`} />}
|
||
{failed > 0 && <div className="bg-red-500 transition-all" style={{ width: `${(failed / total) * 100}%` }} title={`Failed: ${failed}`} />}
|
||
{other > 0 && <div className="bg-gray-300 transition-all" style={{ width: `${(other / total) * 100}%` }} />}
|
||
</div>
|
||
<div className="flex flex-wrap gap-4 mt-3 text-xs text-gray-600">
|
||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 bg-green-500 rounded-full" />Ready: {ready}</span>
|
||
{progressing > 0 && <span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 bg-blue-500 rounded-full" />Progressing: {progressing}</span>}
|
||
{stalled > 0 && <span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 bg-orange-500 rounded-full" />Stalled: {stalled}</span>}
|
||
{failed > 0 && <span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 bg-red-500 rounded-full" />Failed: {failed}</span>}
|
||
{other > 0 && <span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 bg-gray-300 rounded-full" />Other: {other}</span>}
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{/* Unhealthy Resources */}
|
||
{(() => {
|
||
const unhealthyPods = pods.filter((p: any) => p.hasIssues || p.status !== 'Running' || p.ready === false)
|
||
if (unhealthyPods.length === 0) return (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-5 text-center">
|
||
<CheckCircle className="w-8 h-8 text-green-400 mx-auto mb-2" />
|
||
<p className="text-sm text-gray-500">All pods are healthy and running.</p>
|
||
</div>
|
||
)
|
||
return (
|
||
<div className="bg-white rounded-xl border border-red-200">
|
||
<div className="px-5 py-3 border-b border-red-100 bg-red-50 rounded-t-xl">
|
||
<h3 className="text-sm font-semibold text-red-800">⚠ Unhealthy Pods ({unhealthyPods.length})</h3>
|
||
</div>
|
||
<div className="overflow-auto max-h-[320px]">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0 z-10">
|
||
<tr>
|
||
<th className="px-5 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Pod</th>
|
||
<th className="px-5 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||
<th className="px-5 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Reason</th>
|
||
<th className="px-5 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Restarts</th>
|
||
<th className="px-5 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Age</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{unhealthyPods.map((pod: any) => (
|
||
<tr key={`${pod.namespace}/${pod.name}`} className="hover:bg-gray-50 cursor-pointer" onClick={() => { setActiveResourceTab('pods'); setSelectedResource({ type: 'pod', namespace: pod.namespace, name: pod.name }) }}>
|
||
<td className="px-5 py-2.5 text-sm font-medium text-gray-900">{pod.name}</td>
|
||
<td className="px-5 py-2.5"><span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${pod.status === 'Running' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800'}`}>{pod.status}</span></td>
|
||
<td className="px-5 py-2.5 text-sm text-gray-600">{pod.issues?.[0]?.reason || (pod.ready === false ? 'Not Ready' : '-')}</td>
|
||
<td className="px-5 py-2.5 text-sm text-gray-600">{pod.restartCount ?? 0}</td>
|
||
<td className="px-5 py-2.5 text-sm text-gray-500">{timeAgo(pod.creationTimestamp || pod.startTime || '')}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* Recent Warning Events */}
|
||
{(() => {
|
||
const warningEvents = events.filter((e: any) => e.type === 'Warning')
|
||
if (warningEvents.length === 0) return null
|
||
const kindToTab: Record<string, string> = { Pod: 'pods', Deployment: 'deployments', Service: 'services', ReplicaSet: 'deployments', Node: 'overview' }
|
||
const kindToType: Record<string, 'pod' | 'deployment' | 'service' | null> = { Pod: 'pod', Deployment: 'deployment', Service: 'service', ReplicaSet: 'deployment', Node: null }
|
||
return (
|
||
<div className="bg-white rounded-xl border border-amber-200">
|
||
<div className="px-5 py-3 border-b border-amber-100 bg-amber-50 rounded-t-xl">
|
||
<h3 className="text-sm font-semibold text-amber-800">Recent Warning Events</h3>
|
||
</div>
|
||
<div className="overflow-auto max-h-[320px]">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0 z-10">
|
||
<tr>
|
||
<th className="px-5 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">When</th>
|
||
<th className="px-5 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Resource</th>
|
||
<th className="px-5 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Reason</th>
|
||
<th className="px-5 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Message</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{warningEvents.map((event: any, idx: number) => {
|
||
const kind = event.involvedObject?.kind || ''
|
||
const name = event.involvedObject?.name || ''
|
||
const tab = kindToTab[kind]
|
||
const resType = kindToType[kind]
|
||
const ns = event.involvedObject?.namespace || event.metadata?.namespace || selectedNamespace || ''
|
||
return (
|
||
<tr
|
||
key={event.uid || event.metadata?.uid || idx}
|
||
className="hover:bg-gray-50 cursor-pointer"
|
||
onClick={() => {
|
||
if (tab) setActiveResourceTab(tab as any)
|
||
if (resType && name) setSelectedResource({ type: resType, namespace: ns || selectedNamespace, name })
|
||
if (tab === 'overview' && kind === 'Node') setSelectedResource(null)
|
||
}}
|
||
>
|
||
<td className="px-5 py-2.5 text-sm text-gray-500 whitespace-nowrap">{timeAgo(event.lastTimestamp || event.eventTime || '')}</td>
|
||
<td className="px-5 py-2.5 text-sm font-medium text-blue-600 underline decoration-blue-600/50">{event.involvedObject?.kind}/{event.involvedObject?.name}</td>
|
||
<td className="px-5 py-2.5"><span className="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 rounded">{event.reason}</span></td>
|
||
<td className="px-5 py-2.5 text-sm text-gray-600 max-w-md truncate" title={event.message}>{event.message}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* Recent Activity from Audit Logs */}
|
||
<div className="bg-white rounded-xl border border-gray-200">
|
||
<div className="px-5 py-3 border-b border-gray-200">
|
||
<h3 className="text-sm font-semibold text-gray-700">Recent Activity</h3>
|
||
</div>
|
||
{auditLogs.length === 0 ? (
|
||
<div className="p-6 text-center text-sm text-gray-400">No recent activity recorded.</div>
|
||
) : (
|
||
<div className="divide-y divide-gray-100 overflow-y-auto max-h-[320px]">
|
||
{auditLogs.map((log: any, idx: number) => {
|
||
const resourceType = (log.resourceType || '').toLowerCase()
|
||
const tabMap: Record<string, string> = { deployments: 'deployments', pods: 'pods', services: 'services', configmaps: 'configmaps', secrets: 'secrets', cronjobs: 'cronjobs', jobs: 'jobs', ingresses: 'ingresses', pvcs: 'pvcs', namespaces: 'overview' }
|
||
const typeMap: Record<string, 'deployment' | 'pod' | 'service' | 'configmap' | 'secret' | 'cronjob' | 'job' | 'ingress' | 'pvc' | null> = { deployments: 'deployment', pods: 'pod', services: 'service', configmaps: 'configmap', secrets: 'secret', cronjobs: 'cronjob', jobs: 'job', ingresses: 'ingress', pvcs: 'pvc', namespaces: null }
|
||
const tab = tabMap[resourceType]
|
||
const resType = typeMap[resourceType]
|
||
const ns = log.namespace || selectedNamespace || ''
|
||
return (
|
||
<div
|
||
key={log.id || idx}
|
||
className="px-5 py-3 flex items-center justify-between text-sm hover:bg-gray-50 cursor-pointer"
|
||
onClick={() => {
|
||
if (tab) setActiveResourceTab(tab as any)
|
||
if (resType && log.resourceName) setSelectedResource({ type: resType, namespace: ns, name: log.resourceName })
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className={`w-2 h-2 rounded-full ${log.action === 'DELETE' ? 'bg-red-500' : log.action === 'IMAGE_UPDATE' ? 'bg-blue-500' : log.action === 'SCALE' ? 'bg-purple-500' : log.action === 'RESTART' ? 'bg-orange-500' : 'bg-green-500'}`} />
|
||
<span className="text-gray-500 font-medium">{log.user || 'system'}</span>
|
||
<span className="px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{log.action}</span>
|
||
<span className="text-gray-700 text-blue-600 underline decoration-blue-600/50">{log.resourceType}/{log.resourceName}</span>
|
||
</div>
|
||
<span className="text-gray-400 text-xs whitespace-nowrap">{timeAgo(log.timestamp)}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bulk Update Controls - Show for deployments */}
|
||
{activeResourceTab === 'deployments' && deployments.length > 0 && (
|
||
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4 mb-4">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<div className="flex items-center gap-4 sm:gap-6 flex-wrap">
|
||
<span className="text-sm font-medium text-gray-700">Update Mode:</span>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="updateMode"
|
||
value="selective"
|
||
checked={updateMode === 'selective'}
|
||
onChange={(e) => setUpdateMode(e.target.value as 'selective' | 'bulk')}
|
||
className="w-4 h-4 text-blue-600"
|
||
/>
|
||
<span className="text-sm text-gray-700">Selective</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="updateMode"
|
||
value="bulk"
|
||
checked={updateMode === 'bulk'}
|
||
onChange={(e) => setUpdateMode(e.target.value as 'selective' | 'bulk')}
|
||
className="w-4 h-4 text-blue-600"
|
||
/>
|
||
<span className="text-sm text-gray-700">Bulk Update</span>
|
||
</label>
|
||
</div>
|
||
{updateMode === 'bulk' && (
|
||
<button
|
||
onClick={() => {
|
||
setShowBulkUpdateWizard(true)
|
||
setBulkUpdateStep('select')
|
||
setBulkUpdateActiveTab('')
|
||
setBulkUpdateContainerImages(new Map())
|
||
setBulkUpdateContainerTagOnly(new Map())
|
||
if (bulkUpdateSelections.size === 0) {
|
||
const allKeys = new Set(deployments.map(d => `${d.namespace}/${d.name}`))
|
||
setBulkUpdateSelections(allKeys)
|
||
}
|
||
}}
|
||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||
>
|
||
<RefreshCw className="w-4 h-4" />
|
||
Open Bulk Update Wizard
|
||
</button>
|
||
)}
|
||
</div>
|
||
{imageUpdates.size > 0 && (
|
||
<div className="mt-4 pt-4 border-t border-gray-200 flex items-center justify-between">
|
||
<span className="text-sm text-gray-600">{imageUpdates.size} image(s) pending update</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setShowImageComparison(true)}
|
||
className="px-3 py-1.5 border border-blue-200 text-blue-700 rounded text-sm hover:bg-blue-50"
|
||
>
|
||
<Eye className="w-4 h-4 inline mr-1" />
|
||
Review
|
||
</button>
|
||
<button
|
||
onClick={handleUpdateImages}
|
||
disabled={isUpdating}
|
||
className="px-3 py-1.5 bg-green-600 text-white rounded text-sm hover:bg-green-700 disabled:opacity-50"
|
||
>
|
||
{isUpdating ? <Loader2 className="w-4 h-4 inline animate-spin mr-1" /> : <Save className="w-4 h-4 inline mr-1" />}
|
||
Apply Updates
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Resource Details Section */}
|
||
{/* Detailed Resource View */}
|
||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||
{/* Deployments Tab Content */}
|
||
{activeResourceTab === 'deployments' && (
|
||
<div className="min-w-0 p-3 sm:p-4">
|
||
{/* Deleted Deployments Restore Section - only when deployment delete enabled */}
|
||
{enableDeploymentDelete && deletedDeployments.length > 0 && (
|
||
<div className="mb-4">
|
||
<button
|
||
onClick={() => setShowDeletedDeployments(!showDeletedDeployments)}
|
||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg border border-amber-200 transition-colors"
|
||
>
|
||
<RotateCcw className="w-4 h-4" />
|
||
Restore Deleted Deployments ({deletedDeployments.length})
|
||
<ChevronDown className={`w-4 h-4 transition-transform ${showDeletedDeployments ? 'rotate-180' : ''}`} />
|
||
</button>
|
||
{showDeletedDeployments && (
|
||
<div className="mt-2 bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
|
||
<div className="px-4 py-2 bg-amber-100 border-b border-amber-200">
|
||
<span className="text-sm font-medium text-amber-800">Recently Deleted Deployments</span>
|
||
<span className="ml-2 text-xs text-amber-600">(Session only - will be lost on page refresh)</span>
|
||
</div>
|
||
<div className="divide-y divide-amber-200">
|
||
{deletedDeployments.map((deleted, idx) => (
|
||
<div key={`${deleted.namespace}-${deleted.name}-${idx}`} className="px-4 py-3 flex items-center justify-between hover:bg-amber-100/50">
|
||
<div>
|
||
<div className="font-medium text-gray-900">{deleted.name}</div>
|
||
<div className="text-xs text-gray-500">
|
||
Namespace: {deleted.namespace} | Deleted: {new Date(deleted.deletedAt).toLocaleString()}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => handleRestoreDeployment(deleted)}
|
||
disabled={isActioning}
|
||
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||
>
|
||
<RotateCcw className="w-3.5 h-3.5" />
|
||
Restore
|
||
</button>
|
||
<button
|
||
onClick={() => handleClearDeletedDeployment(deleted)}
|
||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded transition-colors"
|
||
title="Remove from restore list"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="px-4 py-2 bg-amber-100/50 border-t border-amber-200">
|
||
<button
|
||
onClick={() => setDeletedDeployments([])}
|
||
className="text-xs text-amber-700 hover:text-amber-900 underline"
|
||
>
|
||
Clear all from restore list
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{isLoading ? (
|
||
<div className="p-16 text-center">
|
||
<div className="w-12 h-12 mx-auto rounded-full bg-blue-50 flex items-center justify-center mb-4">
|
||
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
||
</div>
|
||
<p className="text-gray-500 font-medium">Loading deployments...</p>
|
||
</div>
|
||
) : deployments.length === 0 ? (
|
||
<div className="p-16 text-center">
|
||
<div className="w-16 h-16 mx-auto rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||
<Server className="w-8 h-8 text-gray-400" />
|
||
</div>
|
||
<p className="text-gray-500 font-medium">No deployments found</p>
|
||
<p className="text-gray-400 text-sm mt-1">{selectedNamespace === 'all' ? 'in any namespace' : `in namespace "${selectedNamespace}"`}</p>
|
||
</div>
|
||
) : (
|
||
<div className="w-full overflow-x-auto rounded-lg border border-gray-200 shadow-sm">
|
||
<table className="w-full min-w-[800px]">
|
||
<thead>
|
||
<tr className="bg-slate-50 border-b border-gray-200">
|
||
{updateMode === 'selective' && (
|
||
<th className="w-10 px-3 py-3 text-left text-[10px] font-semibold text-gray-600 uppercase tracking-wider">
|
||
<input
|
||
type="checkbox"
|
||
checked={
|
||
deployments.length > 0 &&
|
||
deployments.every((d) =>
|
||
selectedDeployments.has(`${d.namespace}/${d.name}`)
|
||
)
|
||
}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedDeployments(
|
||
new Set(
|
||
deployments.map((d) => `${d.namespace}/${d.name}`)
|
||
)
|
||
)
|
||
} else {
|
||
setSelectedDeployments(new Set())
|
||
}
|
||
}}
|
||
className="w-4 h-4 rounded border-gray-300 bg-white text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
</th>
|
||
)}
|
||
{selectedNamespace === 'all' && (
|
||
<th className="w-24 px-3 py-3 text-left text-[10px] font-semibold text-gray-600 uppercase tracking-wider">
|
||
Namespace
|
||
</th>
|
||
)}
|
||
<th className="px-3 py-3 text-left text-[10px] font-semibold text-gray-600 uppercase tracking-wider">
|
||
Deployment
|
||
</th>
|
||
<th className="px-3 py-3 text-left text-[10px] font-semibold text-gray-600 uppercase tracking-wider">
|
||
Container
|
||
</th>
|
||
<th className="min-w-[220px] max-w-[320px] px-3 py-3 text-left text-[10px] font-semibold text-gray-600 uppercase tracking-wider">
|
||
Current Image
|
||
</th>
|
||
<th className="min-w-[220px] max-w-[320px] px-3 py-3 text-left text-[10px] font-semibold text-gray-600 uppercase tracking-wider">
|
||
New Image
|
||
</th>
|
||
<th className="w-28 px-3 py-3 text-left text-[10px] font-semibold text-gray-600 uppercase tracking-wider">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{deployments.map((deployment) =>
|
||
(deployment.containers || []).map((container, idx) => {
|
||
const key = `${deployment.namespace}/${deployment.name}/${container.name}`
|
||
const update = imageUpdates.get(key)
|
||
const isSelected = selectedDeployments.has(
|
||
`${deployment.namespace}/${deployment.name}`
|
||
)
|
||
|
||
return (
|
||
<tr
|
||
key={`${deployment.name}-${container.name}-${idx}`}
|
||
className={`${update ? 'bg-blue-50' : ''} hover:bg-slate-50 transition-colors`}
|
||
>
|
||
{updateMode === 'selective' && idx === 0 && (
|
||
<td
|
||
rowSpan={deployment.containers.length}
|
||
className="px-3 py-3 align-top"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={() =>
|
||
toggleDeploymentSelection(deployment.namespace, deployment.name)
|
||
}
|
||
className="w-4 h-4 rounded border-gray-300 bg-white text-blue-600"
|
||
/>
|
||
</td>
|
||
)}
|
||
{selectedNamespace === 'all' && idx === 0 && (
|
||
<td
|
||
rowSpan={deployment.containers.length}
|
||
className="px-3 py-3 align-top text-xs font-medium text-gray-500 truncate max-w-[96px]"
|
||
title={deployment.namespace}
|
||
>
|
||
{deployment.namespace}
|
||
</td>
|
||
)}
|
||
{idx === 0 && (
|
||
<td
|
||
rowSpan={deployment.containers.length}
|
||
className="px-3 py-3 align-top"
|
||
>
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-sm font-medium text-gray-900">{deployment.name}</span>
|
||
<div className="flex items-center gap-2">
|
||
{/* Status indicator based on deploymentStatus */}
|
||
{deployment.deploymentStatus === 'Ready' ? (
|
||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-100 text-emerald-700">
|
||
<span className="w-1.5 h-1.5 mr-1 rounded-full bg-emerald-500"></span>
|
||
Ready
|
||
</span>
|
||
) : deployment.deploymentStatus === 'Progressing' || deployment.deploymentStatus === 'Updating' ? (
|
||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-100 text-blue-700">
|
||
<span className="w-1.5 h-1.5 mr-1 rounded-full bg-blue-500 animate-pulse"></span>
|
||
{deployment.deploymentStatus}
|
||
</span>
|
||
) : deployment.deploymentStatus === 'Stalled' ? (
|
||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-100 text-orange-700">
|
||
<span className="w-1.5 h-1.5 mr-1 rounded-full bg-orange-500"></span>
|
||
Stalled
|
||
</span>
|
||
) : deployment.deploymentStatus === 'ReplicaFailure' || deployment.deploymentStatus === 'Unavailable' ? (
|
||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-700">
|
||
<span className="w-1.5 h-1.5 mr-1 rounded-full bg-red-500 animate-pulse"></span>
|
||
{deployment.deploymentStatus === 'ReplicaFailure' ? 'Failed' : 'Unavailable'}
|
||
</span>
|
||
) : deployment.deploymentStatus === 'Degraded' ? (
|
||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-100 text-yellow-700">
|
||
<span className="w-1.5 h-1.5 mr-1 rounded-full bg-yellow-500 animate-pulse"></span>
|
||
Degraded
|
||
</span>
|
||
) : deployment.deploymentStatus === 'ScaledToZero' ? (
|
||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600">
|
||
<span className="w-1.5 h-1.5 mr-1 rounded-full bg-gray-500"></span>
|
||
Scaled to 0
|
||
</span>
|
||
) : deployment.readyReplicas === deployment.replicas && deployment.replicas > 0 ? (
|
||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-100 text-emerald-700">
|
||
<span className="w-1.5 h-1.5 mr-1 rounded-full bg-emerald-500"></span>
|
||
Ready
|
||
</span>
|
||
) : deployment.readyReplicas > 0 ? (
|
||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-100 text-yellow-700">
|
||
<span className="w-1.5 h-1.5 mr-1 rounded-full bg-yellow-500 animate-pulse"></span>
|
||
Partial
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-700">
|
||
<span className="w-1.5 h-1.5 mr-1 rounded-full bg-red-500 animate-pulse"></span>
|
||
Not Ready
|
||
</span>
|
||
)}
|
||
<span className="text-[10px] text-gray-500">{deployment.readyReplicas}/{deployment.replicas}</span>
|
||
</div>
|
||
</div>
|
||
{/* Warning triangle for deployments with issues */}
|
||
{deployment.hasIssues && (
|
||
<div className="relative">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const depKey = `${deployment.namespace}-${deployment.name}`
|
||
setExpandedDeploymentIssues(expandedDeploymentIssues === depKey ? null : depKey)
|
||
}}
|
||
className="p-1 text-amber-500 hover:text-amber-600 hover:bg-amber-50 rounded transition-colors"
|
||
title="View deployment issues"
|
||
>
|
||
<AlertTriangle className="w-4 h-4" />
|
||
</button>
|
||
{expandedDeploymentIssues === `${deployment.namespace}-${deployment.name}` && (
|
||
<div className="absolute z-50 left-0 top-8 w-80 bg-white border border-gray-200 rounded-lg shadow-xl p-3 text-xs">
|
||
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-100">
|
||
<span className="font-semibold text-gray-900 flex items-center gap-1">
|
||
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
|
||
Deployment Issues ({deployment.issues?.length || 0})
|
||
</span>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setExpandedDeploymentIssues(null)
|
||
}}
|
||
className="text-gray-400 hover:text-gray-600"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||
{deployment.statusReason && (
|
||
<div className={`p-2 rounded ${
|
||
deployment.deploymentStatus === 'Stalled' || deployment.deploymentStatus === 'ReplicaFailure'
|
||
? 'bg-red-50 border border-red-200'
|
||
: 'bg-amber-50 border border-amber-200'
|
||
}`}>
|
||
<div className="font-medium text-gray-900">{deployment.statusReason}</div>
|
||
{deployment.statusMessage && (
|
||
<div className="mt-1 text-gray-600 break-words">
|
||
{deployment.statusMessage.length > 200
|
||
? `${deployment.statusMessage.substring(0, 200)}...`
|
||
: deployment.statusMessage}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{deployment.issues && deployment.issues.length > 0 && deployment.issues.map((issue, issueIdx) => (
|
||
<div key={issueIdx} className="p-2 rounded bg-amber-50 border border-amber-200">
|
||
<div className="flex items-start gap-2">
|
||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 text-purple-700">
|
||
{issue.conditionType}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-medium text-gray-900">{issue.reason}</div>
|
||
{issue.message && (
|
||
<div className="mt-1 text-gray-600 break-words">
|
||
{issue.message.length > 150
|
||
? `${issue.message.substring(0, 150)}...`
|
||
: issue.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* Show deployment conditions */}
|
||
{deployment.conditions && deployment.conditions.some(c => c.status === 'False') && (
|
||
<div className="mt-3 pt-2 border-t border-gray-100">
|
||
<div className="text-[10px] font-semibold text-gray-700 mb-1">Conditions:</div>
|
||
<div className="space-y-1">
|
||
{deployment.conditions.filter(c => c.status === 'False').map((cond, condIdx) => (
|
||
<div key={condIdx} className="flex items-center gap-1 text-[10px]">
|
||
<XCircle className="w-3 h-3 text-red-500" />
|
||
<span className="font-medium">{cond.type}:</span>
|
||
<span className="text-gray-600">{cond.reason || 'Unknown'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</td>
|
||
)}
|
||
<td className="px-3 py-3 text-xs text-gray-600" title={container.name}>
|
||
{container.name}
|
||
</td>
|
||
<td className="px-3 py-3 text-gray-600 font-mono text-[11px] min-w-[220px] max-w-[320px]" title={container.image}>
|
||
<span className="block break-all">{container.image}</span>
|
||
</td>
|
||
<td className="px-3 py-3 min-w-[220px] max-w-[320px]">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<input
|
||
type="text"
|
||
key={`${deployment.name}-${container.name}-${container.image}`}
|
||
value={update ? update.image : container.image}
|
||
onChange={(e) =>
|
||
handleImageChange(
|
||
deployment.namespace,
|
||
deployment.name,
|
||
container.name,
|
||
e.target.value,
|
||
container.image
|
||
)
|
||
}
|
||
placeholder={container.image}
|
||
title={update ? update.image : container.image}
|
||
className="flex-1 min-w-0 px-2 py-1.5 bg-white border border-gray-300 rounded text-[11px] font-mono text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||
/>
|
||
{update && update.image !== container.image && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const newImage = update?.image || container.image
|
||
if (!newImage || newImage.trim() === '') {
|
||
setErrorMessage('Please enter a valid image name')
|
||
setTimeout(() => setErrorMessage(null), 2000)
|
||
return
|
||
}
|
||
handleSaveSingleImage(
|
||
deployment.namespace,
|
||
deployment.name,
|
||
container.name,
|
||
newImage,
|
||
container.image
|
||
)
|
||
}}
|
||
disabled={isUpdating}
|
||
className="px-2 py-1.5 bg-emerald-600 text-white text-[10px] rounded hover:bg-emerald-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
||
title="Save"
|
||
>
|
||
{isUpdating ? (
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
) : (
|
||
<Save className="w-3 h-3" />
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
{idx === 0 && (
|
||
<td
|
||
rowSpan={deployment.containers.length}
|
||
className="px-3 py-3 align-top"
|
||
>
|
||
<div className="flex items-center gap-0.5">
|
||
<button
|
||
onClick={() => setSelectedResource({ type: 'deployment', namespace: deployment.namespace, name: deployment.name })}
|
||
className="p-1.5 text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded transition-colors"
|
||
title="View details"
|
||
>
|
||
<Eye className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setScaleReplicas(deployment.replicas)
|
||
setActionModal({
|
||
type: 'scale',
|
||
resourceType: 'deployment',
|
||
namespace: deployment.namespace,
|
||
name: deployment.name,
|
||
currentReplicas: deployment.replicas
|
||
})
|
||
}}
|
||
className="p-1.5 text-green-600 hover:text-green-700 hover:bg-green-50 rounded transition-colors"
|
||
title="Scale deployment"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setActionModal({
|
||
type: 'restart',
|
||
resourceType: 'deployment',
|
||
namespace: deployment.namespace,
|
||
name: deployment.name
|
||
})
|
||
}}
|
||
className="p-1.5 text-amber-600 hover:text-amber-700 hover:bg-amber-50 rounded transition-colors"
|
||
title="Restart deployment"
|
||
>
|
||
<RotateCw className="w-3.5 h-3.5" />
|
||
</button>
|
||
{enableDeploymentDelete && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setActionModal({
|
||
type: 'delete',
|
||
resourceType: 'deployments',
|
||
namespace: deployment.namespace,
|
||
name: deployment.name
|
||
})
|
||
}}
|
||
className="p-1.5 text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors"
|
||
title="Delete deployment"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => {
|
||
// Focus on first container image input for this deployment
|
||
const firstInput = document.querySelector(
|
||
`input[placeholder="${deployment.containers[0]?.image}"]`
|
||
) as HTMLInputElement
|
||
firstInput?.focus()
|
||
}}
|
||
className="p-2 text-purple-600 hover:text-purple-800 hover:bg-purple-50 rounded transition-colors"
|
||
title="Edit deployment images"
|
||
>
|
||
<Edit className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
)
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{imageUpdates.size > 0 && (
|
||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-gray-600">
|
||
{imageUpdates.size} container image(s) pending update
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setShowImageComparison(true)}
|
||
className="flex items-center gap-2 px-4 py-2 border border-blue-200 text-blue-700 rounded-md hover:bg-blue-50 transition-colors text-sm"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
Review changes
|
||
</button>
|
||
<button
|
||
onClick={handleUpdateImages}
|
||
disabled={isUpdating}
|
||
className="flex items-center gap-2 px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50"
|
||
>
|
||
{isUpdating ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
Updating...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Save className="w-4 h-4" />
|
||
Apply Updates ({imageUpdates.size})
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Pods Tab Content */}
|
||
{activeResourceTab === 'pods' && (
|
||
<div>
|
||
{/* Bulk Actions Bar */}
|
||
{selectedPods.size > 0 && (
|
||
<div className="px-4 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||
<span className="text-sm font-medium text-blue-800">
|
||
{selectedPods.size} pod(s) selected
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setSelectedPods(new Set())}
|
||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded transition-colors"
|
||
>
|
||
Clear Selection
|
||
</button>
|
||
<button
|
||
onClick={handleBulkDeletePods}
|
||
disabled={isDeletingPods}
|
||
className="px-4 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50 flex items-center gap-2 transition-colors"
|
||
>
|
||
{isDeletingPods ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
Deleting...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Trash2 className="w-4 h-4" />
|
||
Delete Selected
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{isLoading ? (
|
||
<div className="p-16 text-center">
|
||
<div className="w-12 h-12 mx-auto rounded-full bg-blue-50 flex items-center justify-center mb-4">
|
||
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
||
</div>
|
||
<p className="text-gray-500 font-medium">Loading pods...</p>
|
||
</div>
|
||
) : pods.length === 0 ? (
|
||
<div className="p-16 text-center">
|
||
<div className="w-16 h-16 mx-auto rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||
<Server className="w-8 h-8 text-gray-400" />
|
||
</div>
|
||
<p className="text-gray-500 font-medium">No pods found</p>
|
||
<p className="text-gray-400 text-sm mt-1">{selectedNamespace === 'all' ? 'in any namespace' : `in namespace "${selectedNamespace}"`}</p>
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="bg-slate-50/80">
|
||
<th className="px-3 py-3.5 text-left">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedPods.size === pods.length && pods.length > 0}
|
||
onChange={handleSelectAllPods}
|
||
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||
title="Select all pods"
|
||
/>
|
||
</th>
|
||
{selectedNamespace === 'all' && (
|
||
<th className="px-5 py-3.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
||
Namespace
|
||
</th>
|
||
)}
|
||
<th className="px-5 py-3.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
||
Pod Name
|
||
</th>
|
||
<th className="px-5 py-3.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
||
Status
|
||
</th>
|
||
<th className="px-5 py-3.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
||
Node
|
||
</th>
|
||
<th className="px-5 py-3.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
||
Pod IP
|
||
</th>
|
||
<th className="px-5 py-3.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
||
Containers
|
||
</th>
|
||
<th className="px-5 py-3.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
||
Restarts
|
||
</th>
|
||
<th className="px-5 py-3.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{pods.map((pod) => {
|
||
const podKey = `${pod.namespace}/${pod.name}`
|
||
return (
|
||
<tr
|
||
key={`${pod.namespace}-${pod.name}`}
|
||
className={`hover:bg-blue-50/50 cursor-pointer transition-colors duration-150 group ${selectedPods.has(podKey) ? 'bg-blue-50' : ''}`}
|
||
onClick={() => setSelectedResource({ type: 'pod', namespace: pod.namespace, name: pod.name })}
|
||
>
|
||
<td className="px-3 py-4" onClick={(e) => e.stopPropagation()}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedPods.has(podKey)}
|
||
onChange={() => handlePodSelect(podKey)}
|
||
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||
/>
|
||
</td>
|
||
{selectedNamespace === 'all' && (
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-700">
|
||
{pod.namespace}
|
||
</td>
|
||
)}
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-900">
|
||
<div className="flex items-center gap-2">
|
||
{pod.name}
|
||
{pod.hasIssues && (
|
||
<div className="relative">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const podKey = `${pod.namespace}-${pod.name}`
|
||
setExpandedPodIssues(expandedPodIssues === podKey ? null : podKey)
|
||
}}
|
||
className="p-1 text-amber-500 hover:text-amber-600 hover:bg-amber-50 rounded transition-colors"
|
||
title="View pod issues"
|
||
>
|
||
<AlertTriangle className="w-4 h-4" />
|
||
</button>
|
||
{expandedPodIssues === `${pod.namespace}-${pod.name}` && (
|
||
<div className="absolute z-50 left-0 top-8 w-80 bg-white border border-gray-200 rounded-lg shadow-xl p-3 text-xs">
|
||
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-100">
|
||
<span className="font-semibold text-gray-900 flex items-center gap-1">
|
||
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
|
||
Pod Issues ({pod.issues?.length || 0})
|
||
</span>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setExpandedPodIssues(null)
|
||
}}
|
||
className="text-gray-400 hover:text-gray-600"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||
{pod.issues && pod.issues.length > 0 ? (
|
||
pod.issues.map((issue, idx) => (
|
||
<div key={idx} className={`p-2 rounded ${
|
||
issue.reason === 'CrashLoopBackOff' || issue.reason === 'OOMKilled' || issue.reason === 'Error'
|
||
? 'bg-red-50 border border-red-200'
|
||
: issue.reason === 'HighRestartCount'
|
||
? 'bg-orange-50 border border-orange-200'
|
||
: 'bg-amber-50 border border-amber-200'
|
||
}`}>
|
||
<div className="flex items-start gap-2">
|
||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||
issue.type === 'container' || issue.type === 'initContainer'
|
||
? 'bg-blue-100 text-blue-700'
|
||
: issue.type === 'condition'
|
||
? 'bg-purple-100 text-purple-700'
|
||
: issue.type === 'restarts'
|
||
? 'bg-orange-100 text-orange-700'
|
||
: 'bg-gray-100 text-gray-700'
|
||
}`}>
|
||
{issue.type === 'initContainer' ? 'init' : issue.type}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-medium text-gray-900">
|
||
{issue.reason}
|
||
{issue.exitCode !== undefined && (
|
||
<span className="ml-1 text-gray-500">(exit: {issue.exitCode})</span>
|
||
)}
|
||
</div>
|
||
{issue.containerName && (
|
||
<div className="text-gray-500 text-[10px]">
|
||
Container: {issue.containerName}
|
||
</div>
|
||
)}
|
||
{issue.conditionType && (
|
||
<div className="text-gray-500 text-[10px]">
|
||
Condition: {issue.conditionType}
|
||
</div>
|
||
)}
|
||
{issue.message && (
|
||
<div className="mt-1 text-gray-600 break-words">
|
||
{issue.message.length > 150
|
||
? `${issue.message.substring(0, 150)}...`
|
||
: issue.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="text-gray-500 italic">
|
||
Pod has issues but no specific details available.
|
||
Check container status and conditions.
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Show pod conditions if available */}
|
||
{pod.conditions && pod.conditions.some(c => c.status === 'False') && (
|
||
<div className="mt-3 pt-2 border-t border-gray-100">
|
||
<div className="text-[10px] font-semibold text-gray-700 mb-1">Conditions:</div>
|
||
<div className="space-y-1">
|
||
{pod.conditions.filter(c => c.status === 'False').map((cond, idx) => (
|
||
<div key={idx} className="flex items-center gap-1 text-[10px]">
|
||
<XCircle className="w-3 h-3 text-red-500" />
|
||
<span className="font-medium">{cond.type}:</span>
|
||
<span className="text-gray-600">{cond.reason || 'Unknown'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-5 py-4 text-sm">
|
||
<span
|
||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md ${
|
||
pod.status === 'Running' && pod.ready
|
||
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-600/20'
|
||
: pod.status === 'Pending'
|
||
? 'bg-amber-50 text-amber-700 ring-1 ring-amber-600/20'
|
||
: pod.status === 'Failed' || pod.status === 'Error'
|
||
? 'bg-red-50 text-red-700 ring-1 ring-red-600/20'
|
||
: pod.status === 'Succeeded'
|
||
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-600/20'
|
||
: 'bg-slate-50 text-slate-600 ring-1 ring-slate-600/20'
|
||
}`}
|
||
>
|
||
<span className={`w-1.5 h-1.5 rounded-full ${
|
||
pod.status === 'Running' && pod.ready
|
||
? 'bg-emerald-500'
|
||
: pod.status === 'Pending'
|
||
? 'bg-amber-500 animate-pulse'
|
||
: pod.status === 'Failed' || pod.status === 'Error'
|
||
? 'bg-red-500'
|
||
: pod.status === 'Succeeded'
|
||
? 'bg-blue-500'
|
||
: 'bg-slate-400'
|
||
}`} />
|
||
{pod.status}
|
||
</span>
|
||
</td>
|
||
<td className="px-5 py-4">
|
||
<span className="text-xs text-slate-600 font-mono bg-slate-50 px-2 py-1 rounded">
|
||
{pod.nodeName || '-'}
|
||
</span>
|
||
</td>
|
||
<td className="px-5 py-4">
|
||
<span className="text-xs text-slate-600 font-mono bg-slate-50 px-2 py-1 rounded">
|
||
{pod.podIP || '-'}
|
||
</span>
|
||
</td>
|
||
<td className="px-5 py-4">
|
||
<div className="space-y-1.5">
|
||
{(pod.containers || []).map((container, idx) => (
|
||
<div key={idx} className="flex items-center gap-2">
|
||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||
container.ready ? 'bg-emerald-500' : 'bg-red-500'
|
||
}`} />
|
||
<span className="text-xs font-medium text-slate-700">{container.name}</span>
|
||
<span className="text-xs text-slate-400 font-mono truncate max-w-[200px]" title={container.image}>
|
||
{container.image.split('/').pop()}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td className="px-5 py-4">
|
||
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded ${
|
||
(pod.restartCount || 0) === 0
|
||
? 'bg-slate-50 text-slate-600'
|
||
: (pod.restartCount || 0) < 5
|
||
? 'bg-amber-50 text-amber-700'
|
||
: 'bg-red-50 text-red-700'
|
||
}`}>
|
||
{pod.restartCount || 0}
|
||
</span>
|
||
</td>
|
||
<td className="px-5 py-4">
|
||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setSelectedResource({ type: 'pod', namespace: pod.namespace, name: pod.name })
|
||
}}
|
||
className="p-1.5 text-slate-500 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors"
|
||
title="View pod details"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setDescribePod({ namespace: pod.namespace, name: pod.name })
|
||
}}
|
||
className="p-1.5 text-slate-500 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors"
|
||
title="Describe pod"
|
||
>
|
||
<Info className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setSelectedLogPod({ namespace: pod.namespace, name: pod.name, container: pod.containers?.[0]?.name })
|
||
}}
|
||
className="p-2 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded transition-colors"
|
||
title="View pod logs"
|
||
>
|
||
<FileText className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (confirm(`Are you sure you want to delete pod "${pod.name}" in namespace "${pod.namespace}"?`)) {
|
||
handleDeleteResource('pods', pod.namespace, pod.name)
|
||
}
|
||
}}
|
||
disabled={isActioning}
|
||
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
title="Delete pod"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Services Tab Content */}
|
||
{activeResourceTab === 'services' && (
|
||
<div>
|
||
{isLoading ? (
|
||
<div className="p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-gray-400" />
|
||
<p className="mt-4 text-gray-500">Loading services...</p>
|
||
</div>
|
||
) : services.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500">
|
||
No services found in namespace "{selectedNamespace}"
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Service Name</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cluster IP</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Ports</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{services.map((service) => (
|
||
<tr
|
||
key={service.name}
|
||
className="hover:bg-gray-50"
|
||
>
|
||
<td
|
||
className="px-6 py-4 text-sm font-medium text-gray-900 cursor-pointer"
|
||
onClick={() => setSelectedResource({ type: 'service', namespace: service.namespace, name: service.name })}
|
||
>
|
||
{service.name}
|
||
</td>
|
||
<td
|
||
className="px-6 py-4 text-sm text-gray-600 cursor-pointer"
|
||
onClick={() => setSelectedResource({ type: 'service', namespace: service.namespace, name: service.name })}
|
||
>
|
||
{service.type}
|
||
</td>
|
||
<td
|
||
className="px-6 py-4 text-sm text-gray-600 font-mono text-xs cursor-pointer"
|
||
onClick={() => setSelectedResource({ type: 'service', namespace: service.namespace, name: service.name })}
|
||
>
|
||
{service.clusterIP || '-'}
|
||
</td>
|
||
<td
|
||
className="px-6 py-4 text-sm text-gray-600 cursor-pointer"
|
||
onClick={() => setSelectedResource({ type: 'service', namespace: service.namespace, name: service.name })}
|
||
>
|
||
{(service.ports || []).map((port, idx) => (
|
||
<div key={idx} className="text-xs">
|
||
{port.port}/{port.protocol}
|
||
</div>
|
||
))}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm" onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedResource({ type: 'service', namespace: service.namespace, name: service.name })}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md border border-gray-300"
|
||
title="View or edit service"
|
||
>
|
||
<Settings className="w-3.5 h-3.5" />
|
||
Update
|
||
</button>
|
||
{(service.ports && service.ports.length > 0) ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const firstPort = service.ports[0].port
|
||
setPortForwardModal({ service, targetPort: firstPort, localPort: firstPort })
|
||
setPortForwardCopied(false)
|
||
}}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-md border border-blue-200"
|
||
>
|
||
<Network className="w-3.5 h-3.5" />
|
||
Port forward
|
||
</button>
|
||
) : (
|
||
null
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ConfigMaps Tab Content */}
|
||
{activeResourceTab === 'configmaps' && (
|
||
<div>
|
||
{isLoading ? (
|
||
<div className="p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-gray-400" />
|
||
<p className="mt-4 text-gray-500">Loading configmaps...</p>
|
||
</div>
|
||
) : configmaps.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500">
|
||
No configmaps found in namespace "{selectedNamespace}"
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ConfigMap Name</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Data Keys</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{configmaps.map((cm) => (
|
||
<tr
|
||
key={cm.name}
|
||
className="hover:bg-gray-50 cursor-pointer"
|
||
onClick={() => setSelectedResource({ type: 'configmap', namespace: cm.namespace, name: cm.name })}
|
||
>
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{cm.name}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
<div className="flex flex-wrap gap-1">
|
||
{Object.keys(cm.data || {}).map((key) => (
|
||
<span key={key} className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
||
{key}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
{cm.creationTimestamp ? new Date(cm.creationTimestamp).toLocaleDateString() : '-'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Secrets Tab Content */}
|
||
{activeResourceTab === 'secrets' && (
|
||
<div>
|
||
<div className="flex justify-end mb-4">
|
||
<button
|
||
onClick={() => {
|
||
setShowCreateSecretModal(true)
|
||
setCreateSecretName('')
|
||
setCreateSecretData([{ key: '', value: '' }])
|
||
setCreateSecretError(null)
|
||
}}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 rounded-md"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Create Secret
|
||
</button>
|
||
</div>
|
||
{isLoading ? (
|
||
<div className="p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-gray-400" />
|
||
<p className="mt-4 text-gray-500">Loading secrets...</p>
|
||
</div>
|
||
) : secrets.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500">
|
||
No secrets found in namespace "{selectedNamespace}"
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Secret Name</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Keys</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{secrets.map((s) => (
|
||
<tr
|
||
key={s.name}
|
||
className="hover:bg-gray-50 cursor-pointer"
|
||
onClick={() => setSelectedResource({ type: 'secret', namespace: s.namespace, name: s.name })}
|
||
>
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{s.name}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">{s.type || 'Opaque'}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
<div className="flex flex-wrap gap-1">
|
||
{(s.keys || []).map((key: string) => (
|
||
<span key={key} className="px-2 py-1 bg-amber-100 text-amber-800 rounded text-xs">
|
||
{key}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
{s.age ? new Date(s.age).toLocaleDateString() : '-'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Events Tab Content */}
|
||
{activeResourceTab === 'events' && (
|
||
<div>
|
||
{isLoading ? (
|
||
<div className="p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-gray-400" />
|
||
<p className="mt-4 text-gray-500">Loading events...</p>
|
||
</div>
|
||
) : events.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500">
|
||
No events found in namespace "{selectedNamespace}"
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Object</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reason</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Message</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Count</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Last Seen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{events.map((event) => (
|
||
<tr
|
||
key={event.name}
|
||
className="hover:bg-gray-50 cursor-pointer"
|
||
onClick={() => setSelectedEvent(event)}
|
||
>
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-900">
|
||
{event.involvedObject?.kind}/{event.involvedObject?.name}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm">
|
||
<span
|
||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||
event.type === 'Warning'
|
||
? 'bg-yellow-100 text-yellow-800'
|
||
: 'bg-green-100 text-green-800'
|
||
}`}
|
||
>
|
||
{event.type}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">{event.reason}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600 max-w-md truncate">{event.message}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">{event.count || 0}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
{event.lastTimestamp ? new Date(event.lastTimestamp).toLocaleString() : '-'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* CronJobs Tab Content */}
|
||
{activeResourceTab === 'cronjobs' && (
|
||
<div>
|
||
{isLoading ? (
|
||
<div className="p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-gray-400" />
|
||
<p className="mt-4 text-gray-500">Loading cronjobs...</p>
|
||
</div>
|
||
) : cronjobs.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500">
|
||
No cronjobs found in namespace "{selectedNamespace}"
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Schedule</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Suspend</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Active</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Last Schedule</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Containers</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{cronjobs.map((cronjob) => (
|
||
<tr
|
||
key={`${cronjob.namespace}/${cronjob.name}`}
|
||
className="hover:bg-gray-50 cursor-pointer"
|
||
onClick={() => setSelectedResource({ type: 'cronjob', namespace: cronjob.namespace, name: cronjob.name })}
|
||
>
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{cronjob.name}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600 font-mono">{cronjob.schedule || '-'}</td>
|
||
<td className="px-6 py-4 text-sm">
|
||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${cronjob.suspend ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'}`}>
|
||
{cronjob.suspend ? 'Yes' : 'No'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">{cronjob.active}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
{cronjob.lastScheduleTime ? new Date(cronjob.lastScheduleTime).toLocaleString() : '-'}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
<div className="flex flex-col gap-1">
|
||
{(cronjob.containers || []).map((container) => (
|
||
<span key={container.name} className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
||
{container.name}: {container.image.split(':')[0]}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
{cronjob.creationTimestamp ? new Date(cronjob.creationTimestamp).toLocaleDateString() : '-'}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setSelectedResource({ type: 'cronjob', namespace: cronjob.namespace, name: cronjob.name })
|
||
}}
|
||
className="text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
View Details
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Jobs Tab Content */}
|
||
{activeResourceTab === 'jobs' && (
|
||
<div>
|
||
{isLoading ? (
|
||
<div className="p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-gray-400" />
|
||
<p className="mt-4 text-gray-500">Loading jobs...</p>
|
||
</div>
|
||
) : jobs.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500">
|
||
No jobs found in namespace "{selectedNamespace}"
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Completions</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Succeeded</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Failed</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Active</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Age</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Containers</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{jobs.map((job) => {
|
||
const jobAge = job.startTime ? new Date(job.startTime) : null
|
||
const ageString = jobAge ? `${Math.floor((Date.now() - jobAge.getTime()) / (1000 * 60))}m` : '-'
|
||
return (
|
||
<tr
|
||
key={`${job.namespace}/${job.name}`}
|
||
className="hover:bg-gray-50 cursor-pointer"
|
||
onClick={() => setSelectedResource({ type: 'job', namespace: job.namespace, name: job.name })}
|
||
>
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{job.name}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">{job.succeeded}/{job.completions}</td>
|
||
<td className="px-6 py-4 text-sm">
|
||
<span className="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||
{job.succeeded}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm">
|
||
{job.failed > 0 ? (
|
||
<span className="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||
{job.failed}
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-600">0</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm">
|
||
{job.active > 0 ? (
|
||
<span className="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
||
{job.active}
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-600">0</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">{ageString}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
<div className="flex flex-col gap-1">
|
||
{(job.containers || []).map((container) => (
|
||
<span key={container.name} className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
||
{container.name}: {container.image.split(':')[0]}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setSelectedResource({ type: 'job', namespace: job.namespace, name: job.name })
|
||
}}
|
||
className="text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
View Details
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Flow Tracing Tab Content */}
|
||
{activeResourceTab === 'flow-tracing' && (
|
||
<div className="w-full">
|
||
<FlowVisualization
|
||
apiUrl={apiUrl}
|
||
namespace={selectedNamespace && selectedNamespace !== 'all' ? selectedNamespace : undefined}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Databases Tab Content - only when feature enabled */}
|
||
{enableDatabase && activeResourceTab === 'databases' && (
|
||
<div className="w-full">
|
||
<DatabaseManagement
|
||
apiUrl={apiUrl}
|
||
namespace={selectedNamespace && selectedNamespace !== 'all' ? selectedNamespace : undefined}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* MongoDB Compass Tab Content */}
|
||
{enableMongoDBWizard && activeResourceTab === 'mongodb-wizard' && (
|
||
<div className="w-full">
|
||
<MongoDBWizard
|
||
apiUrl={apiUrl}
|
||
namespace={selectedNamespace && selectedNamespace !== 'all' ? selectedNamespace : undefined}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Adminer Tab Content */}
|
||
{enableAdminerWizard && activeResourceTab === 'adminer-wizard' && (
|
||
<div className="w-full">
|
||
<AdminerWizard
|
||
apiUrl={apiUrl}
|
||
namespace={selectedNamespace && selectedNamespace !== 'all' ? selectedNamespace : undefined}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* ═══ INGRESSES TAB ═══ */}
|
||
{activeResourceTab === 'ingresses' && (
|
||
<div>
|
||
{isLoading ? (
|
||
<div className="p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-gray-400" />
|
||
<p className="mt-4 text-gray-500">Loading ingresses...</p>
|
||
</div>
|
||
) : ingresses.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500">
|
||
No ingresses found in namespace "{selectedNamespace}"
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Class</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Hosts</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Paths</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Backend</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">TLS</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Age</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{ingresses.map((ing) => (
|
||
<tr key={`${ing.namespace}-${ing.name}`} className="hover:bg-gray-50">
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{ing.name}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">{ing.ingressClassName || <span className="text-gray-400">—</span>}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
{ing.rules.map((r, i) => <div key={i} className="font-mono text-xs">{r.host}</div>)}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm">
|
||
<div className="flex flex-wrap gap-1">
|
||
{ing.rules.flatMap((r, ri) => r.paths.map((p, pi) => (
|
||
<span key={`${ri}-${pi}`} className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded text-xs font-mono">{p.path}</span>
|
||
)))}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
<div className="space-y-0.5">
|
||
{ing.rules.flatMap((r, ri) => r.paths.map((p, pi) => (
|
||
p.backend?.serviceName ? <div key={`${ri}-${pi}`} className="text-xs">{p.backend.serviceName}:{p.backend.servicePort}</div> : null
|
||
)))}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm">
|
||
{ing.tls && ing.tls.length > 0 ? (
|
||
<span className="px-2 py-0.5 bg-green-100 text-green-800 rounded-full text-xs font-semibold">🔒 TLS</span>
|
||
) : (
|
||
<span className="text-gray-400">—</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-500">{ing.creationTimestamp ? timeAgo(ing.creationTimestamp) : '-'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ═══ PVCs TAB ═══ */}
|
||
{activeResourceTab === 'pvcs' && (
|
||
<div>
|
||
{isLoading ? (
|
||
<div className="p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-gray-400" />
|
||
<p className="mt-4 text-gray-500">Loading persistent volume claims...</p>
|
||
</div>
|
||
) : pvcs.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500">
|
||
No PVCs found in namespace "{selectedNamespace}"
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Storage Class</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Capacity</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Access Modes</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Volume</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Age</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{pvcs.map((pvc) => (
|
||
<tr key={`${pvc.namespace}-${pvc.name}`} className="hover:bg-gray-50">
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{pvc.name}</td>
|
||
<td className="px-6 py-4 text-sm">
|
||
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${
|
||
pvc.status === 'Bound' ? 'bg-green-100 text-green-800' :
|
||
pvc.status === 'Pending' ? 'bg-yellow-100 text-yellow-800' :
|
||
pvc.status === 'Lost' ? 'bg-red-100 text-red-800' :
|
||
'bg-gray-100 text-gray-800'
|
||
}`}>{pvc.status}</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">{pvc.storageClassName || <span className="text-gray-400">—</span>}</td>
|
||
<td className="px-6 py-4 text-sm font-mono text-gray-900">{pvc.capacity || '—'}</td>
|
||
<td className="px-6 py-4 text-sm">
|
||
<div className="flex flex-wrap gap-1">
|
||
{pvc.accessModes.map((mode) => (
|
||
<span key={mode} className="px-2 py-0.5 bg-purple-100 text-purple-800 rounded text-xs">{mode}</span>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-500 font-mono text-xs">{pvc.volumeName || '—'}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-500">{pvc.creationTimestamp ? timeAgo(pvc.creationTimestamp) : '-'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Image change report: per-deployment image change count + which images (from cluster) - only when feature enabled */}
|
||
{enableImageChangeReport && activeResourceTab === 'image-change-report' && (
|
||
<div className="w-full">
|
||
<div className="bg-white rounded-lg border border-gray-200 p-4 sm:p-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Image change report</h3>
|
||
<p className="text-sm text-gray-600 mb-4">Per deployment: <strong>how many times the image was changed</strong> (count) and <strong>which images</strong> (flow from start to current). Changes made via Selective or Bulk Update in Deployments are reflected here. Download CSV available.</p>
|
||
{(deployedNamespace || selectedNamespace) && (deployedNamespace || selectedNamespace) !== 'all' ? (
|
||
<>
|
||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-100 text-sm text-gray-700">
|
||
<span className="text-xs font-medium text-gray-500">Namespace:</span>
|
||
<span className="font-medium">{deployedNamespace || selectedNamespace}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={loadImageChangeReport}
|
||
disabled={imageChangeReportLoading}
|
||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{imageChangeReportLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <BarChart2 className="w-4 h-4" />}
|
||
Get report
|
||
</button>
|
||
</div>
|
||
{imageChangeReportData && (
|
||
<>
|
||
{/* Current deployment images from cluster - always shown when we have data */}
|
||
{imageChangeReportData.currentDeployments && imageChangeReportData.currentDeployments.length > 0 && (
|
||
<div className="border border-gray-200 rounded-lg overflow-hidden mb-6">
|
||
<div className="px-4 py-2 bg-emerald-50 border-b border-gray-200 flex flex-wrap items-center justify-between gap-2">
|
||
<span className="text-sm font-medium text-gray-800">
|
||
Current deployment images in namespace <strong>{deployedNamespace || selectedNamespace}</strong> (from cluster — change count & flow from ReplicaSet history)
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const rows: string[][] = [
|
||
['Deployment', 'Namespace', 'Image changes', 'Revision', 'Date', 'Container', 'Image'],
|
||
];
|
||
imageChangeReportData.currentDeployments?.forEach((row) => {
|
||
const flow = row.imageFlow || [];
|
||
if (flow.length === 0) {
|
||
rows.push([row.deployment, row.namespace, String(row.changeCount ?? 0), '-', '-', '-', row.images?.map((i) => i.container ? `${i.container}: ${i.image}` : i.image).join('; ') || '']);
|
||
} else {
|
||
flow.forEach((rev, idx) => {
|
||
const date = rev.createdAt ? new Date(rev.createdAt).toISOString().slice(0, 10) : '-';
|
||
rev.images?.forEach((img) => {
|
||
rows.push([row.deployment, row.namespace, String(row.changeCount ?? 0), String(rev.revision ?? idx + 1), date, img.container || '-', img.image || '']);
|
||
});
|
||
});
|
||
}
|
||
});
|
||
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = `image-change-report-${deployedNamespace || selectedNamespace}-${imageChangeReportData.date}.csv`;
|
||
link.click();
|
||
URL.revokeObjectURL(link.href);
|
||
}}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded hover:bg-blue-700"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||
Download CSV
|
||
</button>
|
||
</div>
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="text-left px-4 py-2 font-medium text-gray-700">Service (Deployment)</th>
|
||
<th className="text-right px-4 py-2 font-medium text-gray-700 w-20">Image changes</th>
|
||
<th className="text-left px-4 py-2 font-medium text-gray-700">Current image list</th>
|
||
<th className="text-left px-4 py-2 font-medium text-gray-700">Flow (start → current)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{imageChangeReportData.currentDeployments.map((row, i) => (
|
||
<tr key={`${row.namespace}-${row.deployment}-${i}`} className="hover:bg-gray-50">
|
||
<td className="px-4 py-2 font-medium text-gray-900">{row.deployment}</td>
|
||
<td className="px-4 py-2 text-right font-mono">{row.changeCount ?? 0}</td>
|
||
<td className="px-4 py-2 text-gray-600">
|
||
<ul className="list-disc list-inside space-y-0.5 max-w-md">
|
||
{row.images?.map((img, j) => (
|
||
<li key={j} className="font-mono text-xs" title={img.image}>{img.container ? `${img.container}: ${img.image}` : img.image}</li>
|
||
))}
|
||
</ul>
|
||
</td>
|
||
<td className="px-4 py-2 text-gray-600">
|
||
{row.imageFlow && row.imageFlow.length > 0 ? (
|
||
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 max-w-lg">
|
||
{row.imageFlow.map((rev, idx) => (
|
||
<span key={idx} className="inline-flex items-center gap-0.5">
|
||
{idx > 0 && <span className="text-gray-400">→</span>}
|
||
<span className="font-mono text-xs bg-gray-100 px-1 rounded" title={rev.images?.map((im) => im.image).join(', ')}>
|
||
Rev{rev.revision ?? idx + 1} ({rev.createdAt ? new Date(rev.createdAt).toISOString().slice(0, 10) : '-'}): {rev.images?.map((im) => im.image?.split(':').pop() || im.image).join(', ')}
|
||
</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<span className="text-gray-400">—</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Recent Image Changes - Detailed History by Deployment (collapsible) */}
|
||
{imageChangeReportData.recentInNamespace && imageChangeReportData.recentInNamespace.length > 0 && (() => {
|
||
const recent = imageChangeReportData.recentInNamespace
|
||
const byDeployment = recent.reduce((acc: Record<string, typeof recent>, entry) => {
|
||
const key = entry.deployment || 'unknown'
|
||
if (!acc[key]) acc[key] = []
|
||
acc[key].push(entry)
|
||
return acc
|
||
}, {})
|
||
const deploymentNames = Object.keys(byDeployment)
|
||
const toggleExpanded = (key: string) => {
|
||
setExpandedImageHistoryDeployments(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(key)) next.delete(key)
|
||
else next.add(key)
|
||
return next
|
||
})
|
||
}
|
||
return (
|
||
<div className="space-y-2">
|
||
<div className="px-4 py-2 bg-blue-50 border border-gray-200 rounded-t-lg flex flex-wrap items-center justify-between gap-2">
|
||
<span className="text-sm font-medium text-gray-800">
|
||
Recent image changes — by deployment ({recent.length} entries)
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const rows: string[][] = [
|
||
['Date', 'Time', 'Deployment', 'Container', 'Old Image', 'New Image', 'Source', 'Changed By'],
|
||
]
|
||
recent.forEach((entry) => {
|
||
const dateStr = entry.at ? new Date(entry.at).toLocaleDateString() : entry.date
|
||
const timeStr = entry.at ? new Date(entry.at).toLocaleTimeString() : ''
|
||
rows.push([
|
||
dateStr,
|
||
timeStr,
|
||
entry.deployment || '',
|
||
entry.container || '',
|
||
entry.oldImage || '',
|
||
entry.image || '',
|
||
entry.source === 'ui-single' ? 'UI - Single Edit' : entry.source === 'ui-bulk' ? 'UI - Bulk Update' : entry.source === 'ui-selective' ? 'UI - Selective Update' : entry.source === 'api' ? 'API / Terminal' : entry.source || 'Unknown',
|
||
entry.user || 'anonymous',
|
||
])
|
||
})
|
||
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n')
|
||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' })
|
||
const link = document.createElement('a')
|
||
link.href = URL.createObjectURL(blob)
|
||
link.download = `image-change-history-${deployedNamespace || selectedNamespace}-${imageChangeReportData.date}.csv`
|
||
link.click()
|
||
URL.revokeObjectURL(link.href)
|
||
}}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded hover:bg-blue-700"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||
Download History CSV
|
||
</button>
|
||
</div>
|
||
{deploymentNames.map((depName) => {
|
||
const entries = byDeployment[depName]
|
||
const count = entries.length
|
||
const isExpanded = expandedImageHistoryDeployments.has(depName)
|
||
return (
|
||
<div key={depName} className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleExpanded(depName)}
|
||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 transition-colors"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-semibold text-gray-900">{depName}</span>
|
||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
|
||
{count} image update{count !== 1 ? 's' : ''}
|
||
</span>
|
||
</div>
|
||
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
|
||
</button>
|
||
{isExpanded && (
|
||
<div className="border-t border-gray-200">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="text-left px-4 py-2 font-medium text-gray-700">When</th>
|
||
<th className="text-left px-4 py-2 font-medium text-gray-700">Deployment</th>
|
||
<th className="text-left px-4 py-2 font-medium text-gray-700">Container</th>
|
||
<th className="text-left px-4 py-2 font-medium text-gray-700">Old Image → New Image</th>
|
||
<th className="text-left px-4 py-2 font-medium text-gray-700">Source</th>
|
||
<th className="text-left px-4 py-2 font-medium text-gray-700">Changed By</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{entries.map((entry, i) => {
|
||
const sourceLabel = entry.source === 'ui-single' ? 'UI - Single Edit' : entry.source === 'ui-bulk' ? 'UI - Bulk Update' : entry.source === 'ui-selective' ? 'UI - Selective' : entry.source === 'api' ? 'API / Terminal' : entry.source || 'Unknown'
|
||
const sourceBg = entry.source === 'ui-single' ? 'bg-green-100 text-green-800' : entry.source === 'ui-bulk' ? 'bg-purple-100 text-purple-800' : entry.source === 'ui-selective' ? 'bg-blue-100 text-blue-800' : entry.source === 'api' ? 'bg-orange-100 text-orange-800' : 'bg-gray-100 text-gray-700'
|
||
return (
|
||
<tr key={i} className="hover:bg-gray-50">
|
||
<td className="px-4 py-2 text-gray-600 whitespace-nowrap">
|
||
<div className="text-xs">{entry.at ? new Date(entry.at).toLocaleDateString() : entry.date}</div>
|
||
<div className="text-xs text-gray-400">{entry.at ? new Date(entry.at).toLocaleTimeString() : ''}</div>
|
||
</td>
|
||
<td className="px-4 py-2 font-medium text-gray-900">{entry.deployment}</td>
|
||
<td className="px-4 py-2 text-gray-600 font-mono text-xs">{entry.container || '—'}</td>
|
||
<td className="px-4 py-2">
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
{entry.oldImage ? (
|
||
<>
|
||
<span className="font-mono text-xs bg-red-50 text-red-700 px-1.5 py-0.5 rounded" title={entry.oldImage}>{entry.oldImage.length > 40 ? '...' + entry.oldImage.slice(-37) : entry.oldImage}</span>
|
||
<span className="text-gray-400">→</span>
|
||
<span className="font-mono text-xs bg-green-50 text-green-700 px-1.5 py-0.5 rounded" title={entry.image}>{(entry.image || '').length > 40 ? '...' + (entry.image || '').slice(-37) : entry.image}</span>
|
||
</>
|
||
) : (
|
||
<span className="font-mono text-xs bg-green-50 text-green-700 px-1.5 py-0.5 rounded" title={entry.image}>{(entry.image || '').length > 50 ? '...' + (entry.image || '').slice(-47) : entry.image}</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-2">
|
||
<span className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full ${sourceBg}`}>{sourceLabel}</span>
|
||
</td>
|
||
<td className="px-4 py-2 text-gray-600 text-xs">{entry.user || 'anonymous'}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* No audit data message */}
|
||
{imageChangeReportData.recentInNamespace && imageChangeReportData.recentInNamespace.length === 0 && (
|
||
<div className="border border-gray-200 rounded-lg p-4 text-center text-gray-500 text-sm">
|
||
No image change history found in audit logs. Changes made via the UI will appear here after you update images.
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="py-6 text-center text-gray-500 text-sm">
|
||
Connect to the cluster to see the image change report for your deployed namespace.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Download Images - Auto Excel Download */}
|
||
{activeResourceTab === 'download-images' && (() => {
|
||
// Auto-trigger Excel download when this tab is selected
|
||
const downloadImagesExcel = () => {
|
||
// Find max containers in any deployment
|
||
const maxContainers = Math.max(...deployments.map(d => d.containers.length), 1)
|
||
|
||
// Build header with Container 1, Container 2, Container 3 columns
|
||
const header = ['S.No', 'Deployment', 'Namespace']
|
||
for (let i = 1; i <= maxContainers; i++) {
|
||
header.push(`Container ${i} Name`, `Container ${i} Image`, `Container ${i} Tag`)
|
||
}
|
||
|
||
const rows: string[][] = [header]
|
||
|
||
let sno = 1
|
||
deployments.forEach(d => {
|
||
const row: string[] = [
|
||
String(sno++),
|
||
d.name,
|
||
d.namespace
|
||
]
|
||
|
||
// Add each container in separate columns
|
||
for (let i = 0; i < maxContainers; i++) {
|
||
if (d.containers[i]) {
|
||
const imageParts = d.containers[i].image.split(':')
|
||
const imageName = imageParts[0]
|
||
const imageTag = imageParts[1] || 'latest'
|
||
row.push(d.containers[i].name, imageName, imageTag)
|
||
} else {
|
||
row.push('', '', '')
|
||
}
|
||
}
|
||
|
||
rows.push(row)
|
||
})
|
||
|
||
// Convert to CSV format (Excel compatible)
|
||
const csvContent = rows.map(row =>
|
||
row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(',')
|
||
).join('\n')
|
||
|
||
// Create and download file
|
||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||
const link = document.createElement('a')
|
||
const url = URL.createObjectURL(blob)
|
||
link.setAttribute('href', url)
|
||
link.setAttribute('download', `${selectedNamespace}-images-${new Date().toISOString().split('T')[0]}.csv`)
|
||
link.style.visibility = 'hidden'
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
|
||
// Switch back to deployments tab after download
|
||
setTimeout(() => setActiveResourceTab('deployments'), 100)
|
||
}
|
||
|
||
// Trigger download immediately
|
||
setTimeout(downloadImagesExcel, 100)
|
||
|
||
return (
|
||
<div className="w-full p-6 flex items-center justify-center min-h-[300px]">
|
||
<div className="text-center">
|
||
<div className="w-16 h-16 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
|
||
<svg className="w-8 h-8 text-green-600 animate-bounce" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||
</svg>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Downloading Images List...</h3>
|
||
<p className="text-gray-600">
|
||
Exporting {deployments.reduce((acc, d) => acc + d.containers.length, 0)} container images from <strong>{selectedNamespace}</strong> namespace
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Old Pods Table - Removed (now in tabbed pane) */}
|
||
{false && isConnected && selectedNamespace && showPods && (
|
||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-6">
|
||
<div className="px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-orange-50 to-amber-50 flex items-center justify-between">
|
||
<h3 className="text-lg font-semibold text-gray-800">
|
||
Pods ({pods.length}) {selectedNamespace === 'all' ? '- All Namespaces' : `- Namespace: ${selectedNamespace}`}
|
||
</h3>
|
||
<button
|
||
onClick={() => setShowPods(false)}
|
||
className="text-gray-500 hover:text-gray-700 px-3 py-1 text-sm"
|
||
>
|
||
Hide
|
||
</button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-gray-400" />
|
||
<p className="mt-4 text-gray-500">Loading pods...</p>
|
||
</div>
|
||
) : pods.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500">
|
||
No pods found {selectedNamespace === 'all' ? 'in any namespace' : `in namespace "${selectedNamespace}"`}
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
{selectedNamespace === 'all' && (
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Namespace
|
||
</th>
|
||
)}
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Pod Name
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Status
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Node
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Pod IP
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Containers
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Restarts
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{pods.map((pod) => (
|
||
<tr key={`${pod.namespace}-${pod.name}`} className="hover:bg-gray-50">
|
||
{selectedNamespace === 'all' && (
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-700">
|
||
{pod.namespace}
|
||
</td>
|
||
)}
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-900">
|
||
<div className="flex items-center gap-2">
|
||
{pod.name}
|
||
{pod.hasIssues && (
|
||
<div className="relative">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const podKey = `${pod.namespace}-${pod.name}`
|
||
setExpandedPodIssues(expandedPodIssues === podKey ? null : podKey)
|
||
}}
|
||
className="p-1 text-amber-500 hover:text-amber-600 hover:bg-amber-50 rounded transition-colors"
|
||
title="View pod issues"
|
||
>
|
||
<AlertTriangle className="w-4 h-4" />
|
||
</button>
|
||
{expandedPodIssues === `${pod.namespace}-${pod.name}` && (
|
||
<div className="absolute z-50 left-0 top-8 w-80 bg-white border border-gray-200 rounded-lg shadow-xl p-3 text-xs">
|
||
<div className="flex items-center justify-between mb-2 pb-2 border-b border-gray-100">
|
||
<span className="font-semibold text-gray-900 flex items-center gap-1">
|
||
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
|
||
Pod Issues ({pod.issues?.length || 0})
|
||
</span>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setExpandedPodIssues(null)
|
||
}}
|
||
className="text-gray-400 hover:text-gray-600"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||
{pod.issues && pod.issues.length > 0 ? (
|
||
pod.issues.map((issue, idx) => (
|
||
<div key={idx} className={`p-2 rounded ${
|
||
issue.reason === 'CrashLoopBackOff' || issue.reason === 'OOMKilled' || issue.reason === 'Error'
|
||
? 'bg-red-50 border border-red-200'
|
||
: issue.reason === 'HighRestartCount'
|
||
? 'bg-orange-50 border border-orange-200'
|
||
: 'bg-amber-50 border border-amber-200'
|
||
}`}>
|
||
<div className="flex items-start gap-2">
|
||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||
issue.type === 'container' || issue.type === 'initContainer'
|
||
? 'bg-blue-100 text-blue-700'
|
||
: issue.type === 'condition'
|
||
? 'bg-purple-100 text-purple-700'
|
||
: issue.type === 'restarts'
|
||
? 'bg-orange-100 text-orange-700'
|
||
: 'bg-gray-100 text-gray-700'
|
||
}`}>
|
||
{issue.type === 'initContainer' ? 'init' : issue.type}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-medium text-gray-900">
|
||
{issue.reason}
|
||
{issue.exitCode !== undefined && (
|
||
<span className="ml-1 text-gray-500">(exit: {issue.exitCode})</span>
|
||
)}
|
||
</div>
|
||
{issue.containerName && (
|
||
<div className="text-gray-500 text-[10px]">
|
||
Container: {issue.containerName}
|
||
</div>
|
||
)}
|
||
{issue.conditionType && (
|
||
<div className="text-gray-500 text-[10px]">
|
||
Condition: {issue.conditionType}
|
||
</div>
|
||
)}
|
||
{issue.message && (
|
||
<div className="mt-1 text-gray-600 break-words">
|
||
{issue.message.length > 150
|
||
? `${issue.message.substring(0, 150)}...`
|
||
: issue.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="text-gray-500 italic">
|
||
Pod has issues but no specific details available.
|
||
Check container status and conditions.
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Show pod conditions if available */}
|
||
{pod.conditions && pod.conditions.some(c => c.status === 'False') && (
|
||
<div className="mt-3 pt-2 border-t border-gray-100">
|
||
<div className="text-[10px] font-semibold text-gray-700 mb-1">Conditions:</div>
|
||
<div className="space-y-1">
|
||
{pod.conditions.filter(c => c.status === 'False').map((cond, idx) => (
|
||
<div key={idx} className="flex items-center gap-1 text-[10px]">
|
||
<XCircle className="w-3 h-3 text-red-500" />
|
||
<span className="font-medium">{cond.type}:</span>
|
||
<span className="text-gray-600">{cond.reason || 'Unknown'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm">
|
||
<span
|
||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||
pod.status === 'Running' && pod.ready
|
||
? 'bg-green-100 text-green-800'
|
||
: pod.status === 'Pending'
|
||
? 'bg-yellow-100 text-yellow-800'
|
||
: pod.status === 'Failed' || pod.status === 'Error'
|
||
? 'bg-red-100 text-red-800'
|
||
: 'bg-gray-100 text-gray-800'
|
||
}`}
|
||
>
|
||
{pod.status}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600 font-mono text-xs">
|
||
{pod.nodeName || '-'}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600 font-mono text-xs">
|
||
{pod.podIP || '-'}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
<div className="space-y-1">
|
||
{(pod.containers || []).map((container, idx) => (
|
||
<div key={idx} className="text-xs">
|
||
<span className="font-medium">{container.name}:</span>{' '}
|
||
<span className="font-mono">{container.image}</span>
|
||
{container.ready !== undefined && (
|
||
<span
|
||
className={`ml-2 px-1 py-0.5 rounded text-xs ${
|
||
container.ready
|
||
? 'bg-green-100 text-green-700'
|
||
: 'bg-red-100 text-red-700'
|
||
}`}
|
||
>
|
||
{container.ready ? '✓' : '✗'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
{pod.restartCount || 0}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => {
|
||
setErrorMessage(`Viewing pod: ${pod.name} - Status: ${pod.status}`)
|
||
setTimeout(() => setErrorMessage(null), 3000)
|
||
}}
|
||
className="p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded transition-colors"
|
||
title="View pod details"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* Services Table with Edit */}
|
||
{/* Old Services Table - Removed (now in tabbed pane) */}
|
||
{false && isConnected && selectedNamespace && showServices && (
|
||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-6">
|
||
<div className="px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-purple-50 flex items-center justify-between">
|
||
<h3 className="text-lg font-semibold text-gray-800">
|
||
Services ({services.length}) {selectedNamespace === 'all' ? '- All Namespaces' : `- Namespace: ${selectedNamespace}`}
|
||
</h3>
|
||
<button
|
||
onClick={() => setShowServices(false)}
|
||
className="text-gray-500 hover:text-gray-700 px-3 py-1 text-sm"
|
||
>
|
||
Hide
|
||
</button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-gray-400" />
|
||
<p className="mt-4 text-gray-500">Loading services...</p>
|
||
</div>
|
||
) : services.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500">
|
||
No services found {selectedNamespace === 'all' ? 'in any namespace' : `in namespace "${selectedNamespace}"`}
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
{selectedNamespace === 'all' && (
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Namespace
|
||
</th>
|
||
)}
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Service
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Type
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Cluster IP
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Ports
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||
Selector
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{services.map((service) => (
|
||
<tr key={`${service.namespace}-${service.name}`} className="hover:bg-gray-50">
|
||
{selectedNamespace === 'all' && (
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-700">
|
||
{service.namespace}
|
||
</td>
|
||
)}
|
||
<td className="px-6 py-4 text-sm font-medium text-gray-900">
|
||
{service.name}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
||
{service.type}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600 font-mono text-xs">
|
||
{service.clusterIP || '-'}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
{(service.ports || []).map((port, idx) => (
|
||
<div key={idx} className="text-xs">
|
||
{port.port}:{port.targetPort}/{port.protocol}
|
||
{port.name && ` (${port.name})`}
|
||
</div>
|
||
))}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
<div className="flex flex-wrap gap-1">
|
||
{Object.entries(service.selector).map(([key, value]) => (
|
||
<span
|
||
key={key}
|
||
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs"
|
||
>
|
||
{key}={value}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Port Forward Modal (OpenLens-style) */}
|
||
{portForwardModal && (
|
||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||
<div className="flex items-center justify-center min-h-screen px-4">
|
||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75" onClick={() => setPortForwardModal(null)} />
|
||
<div className="relative bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-2">
|
||
<Network className="w-5 h-5 text-blue-600" />
|
||
<h3 className="text-lg font-semibold text-gray-900">Port forward</h3>
|
||
</div>
|
||
<button type="button" onClick={() => setPortForwardModal(null)} className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg">
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
<span className="font-medium text-gray-900">{portForwardModal.service.name}</span>
|
||
<span className="text-gray-500"> · </span>
|
||
<span className="text-gray-500">{portForwardModal.service.namespace}</span>
|
||
</p>
|
||
|
||
<div className="space-y-4 mb-5">
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Target port (service)</label>
|
||
<select
|
||
value={portForwardModal.targetPort}
|
||
onChange={(e) => setPortForwardModal({ ...portForwardModal, targetPort: Number(e.target.value) })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||
>
|
||
{(portForwardModal.service.ports || []).map((p, idx) => (
|
||
<option key={idx} value={p.port}>{p.port}/{p.protocol}{p.name ? ` (${p.name})` : ''}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Local port</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={65535}
|
||
value={portForwardModal.localPort}
|
||
onChange={(e) => setPortForwardModal({ ...portForwardModal, localPort: Number(e.target.value) || portForwardModal.targetPort })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<a
|
||
href={`${apiUrl}/k8s/services/${encodeURIComponent(portForwardModal.service.namespace)}/${encodeURIComponent(portForwardModal.service.name)}/proxy/?port=${portForwardModal.targetPort}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="w-full inline-flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm mb-4"
|
||
>
|
||
<ExternalLink className="w-4 h-4" />
|
||
Open in browser (no kubectl)
|
||
</a>
|
||
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Or run in terminal</p>
|
||
<div className="flex gap-2">
|
||
<code className="flex-1 px-3 py-2 bg-white border border-gray-200 rounded text-xs font-mono text-gray-800 break-all">
|
||
kubectl port-forward -n {portForwardModal.service.namespace} svc/{portForwardModal.service.name} {portForwardModal.localPort}:{portForwardModal.targetPort}
|
||
</code>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const cmd = `kubectl port-forward -n ${portForwardModal.service.namespace} svc/${portForwardModal.service.name} ${portForwardModal.localPort}:${portForwardModal.targetPort}`
|
||
navigator.clipboard.writeText(cmd).then(() => {
|
||
setPortForwardCopied(true)
|
||
setTimeout(() => setPortForwardCopied(false), 2000)
|
||
})
|
||
}}
|
||
className="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg"
|
||
>
|
||
<Copy className="w-3.5 h-3.5" />
|
||
{portForwardCopied ? 'Copied' : 'Copy'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Resource Detail Modal */}
|
||
<ResourceDetailModal
|
||
isOpen={selectedResource !== null}
|
||
onClose={() => setSelectedResource(null)}
|
||
resourceType={selectedResource?.type || null}
|
||
namespace={selectedResource?.namespace || ''}
|
||
name={selectedResource?.name || ''}
|
||
apiUrl={apiUrl}
|
||
onResourceDeleted={loadData}
|
||
/>
|
||
|
||
{/* Describe Pod Modal */}
|
||
{describePod && (
|
||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||
<div className="flex items-center justify-center min-h-screen px-4">
|
||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75" onClick={() => setDescribePod(null)} aria-hidden />
|
||
<div className="relative bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col my-8">
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
||
<h3 className="text-lg font-semibold text-gray-900">
|
||
Describe Pod: {describePod.name}
|
||
<span className="text-sm font-normal text-gray-500 ml-2">({describePod.namespace})</span>
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDescribePod(null)}
|
||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
|
||
aria-label="Close"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 overflow-auto p-4 bg-slate-50">
|
||
{describePodLoading && (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||
</div>
|
||
)}
|
||
{describePodError && (
|
||
<p className="text-red-600 text-sm">{describePodError}</p>
|
||
)}
|
||
{!describePodLoading && !describePodError && describePodContent && (
|
||
<pre className="text-xs font-mono text-slate-800 whitespace-pre-wrap break-words p-4 bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||
{describePodContent}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create Secret Modal */}
|
||
{showCreateSecretModal && (
|
||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||
<div className="flex items-center justify-center min-h-screen px-4">
|
||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75" onClick={() => setShowCreateSecretModal(false)} />
|
||
<div className="relative bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Create Secret</h3>
|
||
<p className="text-sm text-gray-500 mb-4">
|
||
Namespace: <strong>{selectedNamespace}</strong> (key-value pairs are stored as base64)
|
||
</p>
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Secret name</label>
|
||
<input
|
||
type="text"
|
||
value={createSecretName}
|
||
onChange={(e) => setCreateSecretName(e.target.value)}
|
||
placeholder="my-secret"
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
|
||
/>
|
||
</div>
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Data (key-value)</label>
|
||
{createSecretData.map((row, idx) => (
|
||
<div key={idx} className="flex gap-2 mb-2">
|
||
<input
|
||
type="text"
|
||
value={row.key}
|
||
onChange={(e) => {
|
||
const next = [...createSecretData]
|
||
next[idx] = { ...next[idx], key: e.target.value }
|
||
setCreateSecretData(next)
|
||
}}
|
||
placeholder="key"
|
||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={row.value}
|
||
onChange={(e) => {
|
||
const next = [...createSecretData]
|
||
next[idx] = { ...next[idx], value: e.target.value }
|
||
setCreateSecretData(next)
|
||
}}
|
||
placeholder="value"
|
||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCreateSecretData(createSecretData.filter((_, i) => i !== idx))}
|
||
className="p-2 text-red-500 hover:bg-red-50 rounded"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => setCreateSecretData([...createSecretData, { key: '', value: '' }])}
|
||
className="text-sm text-amber-600 hover:text-amber-700 flex items-center gap-1"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Add key-value
|
||
</button>
|
||
</div>
|
||
{createSecretError && (
|
||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
|
||
{createSecretError}
|
||
</div>
|
||
)}
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
onClick={() => setShowCreateSecretModal(false)}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
disabled={createSecretSaving || !createSecretName.trim() || !selectedNamespace || selectedNamespace === 'all'}
|
||
onClick={async () => {
|
||
if (selectedNamespace === 'all' || !selectedNamespace) return
|
||
const data: Record<string, string> = {}
|
||
createSecretData.forEach(({ key, value }) => {
|
||
const k = key.trim()
|
||
if (k) data[k] = value
|
||
})
|
||
if (Object.keys(data).length === 0) {
|
||
setCreateSecretError('Add at least one key-value pair')
|
||
return
|
||
}
|
||
setCreateSecretError(null)
|
||
setCreateSecretSaving(true)
|
||
try {
|
||
const res = await fetch(`${apiUrl}/k8s/secrets`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ namespace: selectedNamespace, name: createSecretName.trim(), data }),
|
||
})
|
||
const json = await res.json()
|
||
if (json.success) {
|
||
setShowCreateSecretModal(false)
|
||
loadData()
|
||
} else {
|
||
setCreateSecretError(json.message || 'Failed to create secret')
|
||
}
|
||
} catch (e: any) {
|
||
setCreateSecretError(e.message || 'Failed to create secret')
|
||
} finally {
|
||
setCreateSecretSaving(false)
|
||
}
|
||
}}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 rounded-md disabled:opacity-50"
|
||
>
|
||
{createSecretSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||
Create
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedLogPod && (
|
||
<PodLogsModal
|
||
isOpen={!!selectedLogPod}
|
||
onClose={() => setSelectedLogPod(null)}
|
||
podName={selectedLogPod.name}
|
||
namespace={selectedLogPod.namespace}
|
||
container={selectedLogPod.container}
|
||
apiUrl={apiUrl}
|
||
/>
|
||
)}
|
||
|
||
{/* Event Detail Modal */}
|
||
{selectedEvent && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="relative bg-white rounded-lg shadow-xl max-w-2xl w-[90vw] max-h-[85vh] overflow-hidden">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-900">Event Details</h3>
|
||
<p className="text-sm text-gray-500">
|
||
{selectedEvent.involvedObject?.kind}/{selectedEvent.involvedObject?.name}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setSelectedEvent(null)}
|
||
className="text-gray-500 hover:text-gray-700"
|
||
>
|
||
<XCircle className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
<div className="p-6 overflow-auto max-h-[70vh] space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<label className="text-xs font-medium text-gray-500 uppercase">Type</label>
|
||
<div className="mt-1">
|
||
<span className={`px-2 py-1 inline-flex text-sm font-semibold rounded-full ${
|
||
selectedEvent.type === 'Warning'
|
||
? 'bg-yellow-100 text-yellow-800'
|
||
: 'bg-green-100 text-green-800'
|
||
}`}>
|
||
{selectedEvent.type}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<label className="text-xs font-medium text-gray-500 uppercase">Reason</label>
|
||
<p className="mt-1 text-sm font-medium text-gray-900">{selectedEvent.reason}</p>
|
||
</div>
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<label className="text-xs font-medium text-gray-500 uppercase">Count</label>
|
||
<p className="mt-1 text-sm font-medium text-gray-900">{selectedEvent.count || 1}</p>
|
||
</div>
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<label className="text-xs font-medium text-gray-500 uppercase">Source</label>
|
||
<p className="mt-1 text-sm font-medium text-gray-900">
|
||
{selectedEvent.source?.component || '-'} / {selectedEvent.source?.host || '-'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<label className="text-xs font-medium text-gray-500 uppercase">Message</label>
|
||
<p className="mt-1 text-sm text-gray-900 whitespace-pre-wrap">{selectedEvent.message}</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<label className="text-xs font-medium text-gray-500 uppercase">First Timestamp</label>
|
||
<p className="mt-1 text-sm text-gray-900">
|
||
{selectedEvent.firstTimestamp ? new Date(selectedEvent.firstTimestamp).toLocaleString() : '-'}
|
||
</p>
|
||
</div>
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<label className="text-xs font-medium text-gray-500 uppercase">Last Timestamp</label>
|
||
<p className="mt-1 text-sm text-gray-900">
|
||
{selectedEvent.lastTimestamp ? new Date(selectedEvent.lastTimestamp).toLocaleString() : '-'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<label className="text-xs font-medium text-gray-500 uppercase">Involved Object</label>
|
||
<div className="mt-1 text-sm text-gray-900">
|
||
<p><span className="font-medium">Kind:</span> {selectedEvent.involvedObject?.kind}</p>
|
||
<p><span className="font-medium">Name:</span> {selectedEvent.involvedObject?.name}</p>
|
||
<p><span className="font-medium">Namespace:</span> {selectedEvent.involvedObject?.namespace}</p>
|
||
{selectedEvent.involvedObject?.uid && (
|
||
<p><span className="font-medium">UID:</span> <code className="text-xs bg-gray-200 px-1 rounded">{selectedEvent.involvedObject.uid}</code></p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{selectedEvent.name && (
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<label className="text-xs font-medium text-gray-500 uppercase">Event Name</label>
|
||
<p className="mt-1 text-sm text-gray-900 font-mono">{selectedEvent.name}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bulk Update Image Wizard Modal */}
|
||
{showBulkUpdateWizard && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="relative bg-white rounded-lg shadow-xl max-w-4xl w-[90vw] max-h-[85vh] overflow-hidden">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-900">Bulk Image Update Wizard</h3>
|
||
<p className="text-sm text-gray-500">
|
||
{bulkUpdateStep === 'select'
|
||
? 'Step 1: Select deployments to update'
|
||
: 'Step 2: Review and apply updates'}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
setShowBulkUpdateWizard(false)
|
||
setBulkUpdateSelections(new Set())
|
||
setBulkUpdateImage('')
|
||
setBulkUpdateTagOnly(false)
|
||
setBulkUpdateActiveTab('')
|
||
setBulkUpdateContainerImages(new Map())
|
||
setBulkUpdateContainerTagOnly(new Map())
|
||
setBulkUpdateStep('select')
|
||
}}
|
||
className="text-gray-500 hover:text-gray-700"
|
||
>
|
||
<XCircle className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 overflow-auto max-h-[65vh]">
|
||
{bulkUpdateStep === 'select' ? (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={bulkUpdateSelections.size === deployments.length && deployments.length > 0}
|
||
onChange={handleSelectAllBulkUpdate}
|
||
className="w-4 h-4"
|
||
/>
|
||
<span className="font-medium text-gray-700">
|
||
Select All ({bulkUpdateSelections.size}/{deployments.length} selected)
|
||
</span>
|
||
</label>
|
||
<div className="text-sm text-gray-500">
|
||
{bulkUpdateSelections.size} deployment(s) selected
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-12"></th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Deployment</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Containers</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Current Images</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200">
|
||
{deployments.map((deployment) => {
|
||
const key = `${deployment.namespace}/${deployment.name}`
|
||
const isSelected = bulkUpdateSelections.has(key)
|
||
return (
|
||
<tr key={key} className={isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'}>
|
||
<td className="px-4 py-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={() => toggleBulkUpdateSelection(deployment.namespace, deployment.name)}
|
||
className="w-4 h-4"
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="font-medium text-gray-900">{deployment.name}</div>
|
||
<div className="text-xs text-gray-500">{deployment.namespace}</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="space-y-1">
|
||
{(deployment.containers || []).map((container, idx) => (
|
||
<div key={idx} className="text-xs text-gray-600">
|
||
{container.name}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="space-y-1">
|
||
{(deployment.containers || []).map((container, idx) => (
|
||
<div key={idx} className="text-xs font-mono text-gray-600">
|
||
{container.image}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Container Tabs Section - Only show if deployments are selected */}
|
||
{bulkUpdateSelections.size > 0 && (() => {
|
||
// Group all containers by type (sidecar vs main)
|
||
const mainContainers: Array<{
|
||
deployment: Deployment
|
||
container: { name: string; image: string }
|
||
}> = []
|
||
const sidecarContainers: Array<{
|
||
deployment: Deployment
|
||
container: { name: string; image: string }
|
||
}> = []
|
||
|
||
deployments.forEach((deployment) => {
|
||
const key = `${deployment.namespace}/${deployment.name}`
|
||
if (bulkUpdateSelections.has(key)) {
|
||
deployment.containers.forEach((container) => {
|
||
const isSidecar = container.name.toLowerCase().includes('sidecar')
|
||
if (isSidecar) {
|
||
sidecarContainers.push({ deployment, container })
|
||
} else {
|
||
mainContainers.push({ deployment, container })
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
// Set initial active tab if not set
|
||
if (!bulkUpdateActiveTab) {
|
||
if (mainContainers.length > 0) {
|
||
setBulkUpdateActiveTab('main')
|
||
} else if (sidecarContainers.length > 0) {
|
||
setBulkUpdateActiveTab('sidecar')
|
||
}
|
||
}
|
||
|
||
const isActiveSidecar = bulkUpdateActiveTab === 'sidecar'
|
||
const activeContainers = isActiveSidecar ? sidecarContainers : mainContainers
|
||
const activeImage = bulkUpdateContainerImages.get(bulkUpdateActiveTab) || ''
|
||
const activeTagOnly = bulkUpdateContainerTagOnly.get(bulkUpdateActiveTab) || false
|
||
|
||
return (
|
||
<div className="mt-6 space-y-4">
|
||
{/* Container Type Tabs */}
|
||
<div className="border-b border-gray-200">
|
||
<div className="flex items-center gap-2 overflow-x-auto">
|
||
{mainContainers.length > 0 && (
|
||
<button
|
||
onClick={() => setBulkUpdateActiveTab('main')}
|
||
className={`px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors ${
|
||
bulkUpdateActiveTab === 'main'
|
||
? 'text-blue-600 border-b-2 border-blue-600'
|
||
: 'text-gray-600 hover:text-gray-900 hover:border-b-2 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
Main Containers ({mainContainers.length})
|
||
</button>
|
||
)}
|
||
{sidecarContainers.length > 0 && (
|
||
<button
|
||
onClick={() => setBulkUpdateActiveTab('sidecar')}
|
||
className={`px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors ${
|
||
bulkUpdateActiveTab === 'sidecar'
|
||
? 'text-blue-600 border-b-2 border-blue-600'
|
||
: 'text-gray-600 hover:text-gray-900 hover:border-b-2 hover:border-gray-300'
|
||
} bg-purple-50`}
|
||
>
|
||
Sidecar Containers ({sidecarContainers.length})
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Selected Container Type's Deployments Table */}
|
||
{activeContainers.length > 0 && (
|
||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200">
|
||
<span className="text-sm font-medium text-gray-700">
|
||
{isActiveSidecar ? 'Sidecar' : 'Main'} Containers ({activeContainers.length} total)
|
||
</span>
|
||
</div>
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Deployment</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Namespace</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Container Name</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Current Image</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200">
|
||
{activeContainers.map((item, idx) => (
|
||
<tr key={`${item.deployment.namespace}/${item.deployment.name}/${item.container.name}-${idx}`} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3">
|
||
<div className="font-medium text-gray-900">{item.deployment.name}</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="text-xs text-gray-500">{item.deployment.namespace}</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="text-xs text-gray-600">{item.container.name}</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="text-xs font-mono text-gray-600">{item.container.image}</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Image Input Section - Different for sidecar vs main containers */}
|
||
<div className="mt-4 space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
id={`bulkUpdateTagOnly-${bulkUpdateActiveTab}`}
|
||
checked={activeTagOnly}
|
||
onChange={(e) => {
|
||
const newMap = new Map(bulkUpdateContainerTagOnly)
|
||
newMap.set(bulkUpdateActiveTab, e.target.checked)
|
||
setBulkUpdateContainerTagOnly(newMap)
|
||
}}
|
||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||
/>
|
||
<label htmlFor={`bulkUpdateTagOnly-${bulkUpdateActiveTab}`} className="text-sm font-medium text-gray-700 cursor-pointer">
|
||
Update tag only (keep base image/service name)
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
{activeTagOnly ? 'New Tag:' : 'New Image:'}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={activeImage}
|
||
onChange={(e) => {
|
||
const newMap = new Map(bulkUpdateContainerImages)
|
||
newMap.set(bulkUpdateActiveTab, e.target.value)
|
||
setBulkUpdateContainerImages(newMap)
|
||
}}
|
||
placeholder={activeTagOnly ? "e.g., 1.12.0-rc5 or latest" : "e.g., nginx:1.25 or myregistry.io/app:v2.0"}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||
/>
|
||
{activeTagOnly && (
|
||
<p className="mt-1 text-xs text-gray-500">
|
||
Only the tag portion will be updated. Base image will remain unchanged.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{(() => {
|
||
// Group all containers by type (sidecar vs main)
|
||
const mainContainers: Array<{
|
||
deployment: Deployment
|
||
container: { name: string; image: string }
|
||
}> = []
|
||
const sidecarContainers: Array<{
|
||
deployment: Deployment
|
||
container: { name: string; image: string }
|
||
}> = []
|
||
|
||
deployments.forEach((deployment) => {
|
||
const key = `${deployment.namespace}/${deployment.name}`
|
||
if (bulkUpdateSelections.has(key)) {
|
||
deployment.containers.forEach((container) => {
|
||
const isSidecar = container.name.toLowerCase().includes('sidecar')
|
||
if (isSidecar) {
|
||
sidecarContainers.push({ deployment, container })
|
||
} else {
|
||
mainContainers.push({ deployment, container })
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
const isActiveSidecar = bulkUpdateActiveTab === 'sidecar'
|
||
const activeContainers = isActiveSidecar ? sidecarContainers : mainContainers
|
||
const activeImage = bulkUpdateContainerImages.get(bulkUpdateActiveTab) || ''
|
||
const activeTagOnly = bulkUpdateContainerTagOnly.get(bulkUpdateActiveTab) || false
|
||
|
||
return (
|
||
<>
|
||
{/* Container Type Tabs */}
|
||
<div className="border-b border-gray-200">
|
||
<div className="flex items-center gap-2 overflow-x-auto">
|
||
{mainContainers.length > 0 && (
|
||
<button
|
||
onClick={() => setBulkUpdateActiveTab('main')}
|
||
className={`px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors ${
|
||
bulkUpdateActiveTab === 'main'
|
||
? 'text-blue-600 border-b-2 border-blue-600'
|
||
: 'text-gray-600 hover:text-gray-900 hover:border-b-2 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
Main Containers ({mainContainers.length})
|
||
</button>
|
||
)}
|
||
{sidecarContainers.length > 0 && (
|
||
<button
|
||
onClick={() => setBulkUpdateActiveTab('sidecar')}
|
||
className={`px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors ${
|
||
bulkUpdateActiveTab === 'sidecar'
|
||
? 'text-blue-600 border-b-2 border-blue-600'
|
||
: 'text-gray-600 hover:text-gray-900 hover:border-b-2 hover:border-gray-300'
|
||
} bg-purple-50`}
|
||
>
|
||
Sidecar Containers ({sidecarContainers.length})
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<h4 className="font-medium text-blue-900 mb-2">Update Summary</h4>
|
||
<div className="text-sm text-blue-800">
|
||
<p>• {bulkUpdateSelections.size} deployment(s) selected</p>
|
||
<p>• Container Type: <span className="font-mono">{isActiveSidecar ? 'Sidecar' : 'Main'}</span></p>
|
||
<p>• Containers to update: {activeContainers.length}</p>
|
||
<p>• New {activeTagOnly ? 'tag' : 'image'}: <span className="font-mono">{activeImage}</span></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Deployment</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Namespace</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Container Name</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Current Image</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase min-w-[300px]">New Image</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200">
|
||
{activeContainers.map((item, idx) => {
|
||
const newImage = activeTagOnly
|
||
? applyTagOnlyUpdate(item.container.image, activeImage.trim())
|
||
: activeImage.trim()
|
||
return (
|
||
<tr key={`${item.deployment.name}-${item.container.name}-${idx}`} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3 text-gray-900">{item.deployment.name}</td>
|
||
<td className="px-4 py-3 text-gray-900">{item.deployment.namespace}</td>
|
||
<td className="px-4 py-3 text-gray-900">{item.container.name}</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-gray-600 break-all">{item.container.image}</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-blue-700 font-medium break-all whitespace-normal" title={newImage}>
|
||
{newImage}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||
<button
|
||
onClick={() => {
|
||
if (bulkUpdateStep === 'preview') {
|
||
setBulkUpdateStep('select')
|
||
} else {
|
||
setShowBulkUpdateWizard(false)
|
||
setBulkUpdateSelections(new Set())
|
||
setBulkUpdateImage('')
|
||
setBulkUpdateTagOnly(false)
|
||
setBulkUpdateActiveTab('')
|
||
setBulkUpdateContainerImages(new Map())
|
||
setBulkUpdateContainerTagOnly(new Map())
|
||
}
|
||
}}
|
||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors"
|
||
>
|
||
{bulkUpdateStep === 'preview' ? 'Back' : 'Cancel'}
|
||
</button>
|
||
<div className="flex items-center gap-2">
|
||
{bulkUpdateStep === 'select' ? (
|
||
<button
|
||
onClick={() => {
|
||
if (bulkUpdateSelections.size === 0) {
|
||
setErrorMessage('Please select at least one deployment')
|
||
return
|
||
}
|
||
if (!bulkUpdateActiveTab || (bulkUpdateActiveTab !== 'main' && bulkUpdateActiveTab !== 'sidecar')) {
|
||
setErrorMessage('Please select a container tab (Main or Sidecar)')
|
||
return
|
||
}
|
||
const containerImage = bulkUpdateContainerImages.get(bulkUpdateActiveTab) || ''
|
||
const containerTagOnly = bulkUpdateContainerTagOnly.get(bulkUpdateActiveTab) || false
|
||
if (!containerImage.trim()) {
|
||
setErrorMessage('Please enter a new ' + (containerTagOnly ? 'tag' : 'image') + ' for ' + bulkUpdateActiveTab + ' containers')
|
||
return
|
||
}
|
||
setBulkUpdateStep('preview')
|
||
}}
|
||
disabled={bulkUpdateSelections.size === 0 || !bulkUpdateActiveTab || (bulkUpdateActiveTab !== 'main' && bulkUpdateActiveTab !== 'sidecar') || !(bulkUpdateContainerImages.get(bulkUpdateActiveTab) || '').trim()}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Next: Review
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={handleBulkUpdateApply}
|
||
disabled={isUpdating}
|
||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||
>
|
||
{isUpdating ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
Updating...
|
||
</>
|
||
) : (
|
||
<>
|
||
<CheckCircle className="w-4 h-4" />
|
||
Apply Updates
|
||
</>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Image comparison modal */}
|
||
{showImageComparison && (
|
||
<div className="fixed inset-0 z-40 flex items-center justify-center">
|
||
<div
|
||
className="absolute inset-0 bg-black bg-opacity-40"
|
||
onClick={() => setShowImageComparison(false)}
|
||
/>
|
||
<div className="relative bg-white rounded-lg shadow-xl max-w-5xl w-[90vw] max-h-[80vh] overflow-hidden z-50">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-900">Image update review</h3>
|
||
<p className="text-sm text-gray-500">
|
||
Compare current vs new images before applying updates
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowImageComparison(false)}
|
||
className="text-gray-500 hover:text-gray-700"
|
||
>
|
||
<XCircle className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
<div className="p-6 overflow-auto max-h-[70vh]">
|
||
{imageUpdates.size === 0 ? (
|
||
<p className="text-sm text-gray-500">No pending image updates.</p>
|
||
) : (
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Namespace</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Deployment</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Container</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Current Image</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">New Image</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200">
|
||
{Array.from(imageUpdates.values()).map((update, idx) => (
|
||
<tr key={idx} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3 text-gray-900">{update.namespace}</td>
|
||
<td className="px-4 py-3 text-gray-900">{update.deployment}</td>
|
||
<td className="px-4 py-3 text-gray-900">{update.container}</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{update.originalImage}</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-blue-700">{update.image}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||
<span className="text-sm text-gray-600">
|
||
{imageUpdates.size} change(s) ready to apply
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setShowImageComparison(false)}
|
||
className="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50"
|
||
>
|
||
Close
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setShowImageComparison(false)
|
||
handleUpdateImages()
|
||
}}
|
||
disabled={isUpdating || imageUpdates.size === 0}
|
||
className="flex items-center gap-2 px-5 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50"
|
||
>
|
||
{isUpdating ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
Applying...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Save className="w-4 h-4" />
|
||
Apply now
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Action Confirmation Modals */}
|
||
{actionModal && (
|
||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onClick={() => setActionModal(null)}></div>
|
||
|
||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||
{actionModal.type === 'scale' && 'Scale Deployment'}
|
||
{actionModal.type === 'restart' && 'Restart Deployment'}
|
||
{actionModal.type === 'delete' && 'Delete Resource'}
|
||
</h3>
|
||
<button
|
||
onClick={() => setActionModal(null)}
|
||
className="text-gray-400 hover:text-gray-500"
|
||
>
|
||
<XCircle className="h-6 w-6" />
|
||
</button>
|
||
</div>
|
||
|
||
{actionModal.type === 'scale' && (
|
||
<div>
|
||
<p className="text-sm text-gray-500 mb-4">
|
||
Scale deployment <strong>{actionModal.name}</strong> in namespace <strong>{actionModal.namespace}</strong>
|
||
</p>
|
||
<div className="flex items-center gap-4">
|
||
<button
|
||
onClick={() => setScaleReplicas(Math.max(0, scaleReplicas - 1))}
|
||
className="p-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||
>
|
||
<Minus className="w-4 h-4" />
|
||
</button>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={scaleReplicas}
|
||
onChange={(e) => setScaleReplicas(Math.max(0, parseInt(e.target.value) || 0))}
|
||
className="w-20 px-3 py-2 border border-gray-300 rounded-md text-center focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<button
|
||
onClick={() => setScaleReplicas(scaleReplicas + 1)}
|
||
className="p-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
</button>
|
||
<span className="text-sm text-gray-600">replicas</span>
|
||
</div>
|
||
{actionModal.currentReplicas !== undefined && (
|
||
<p className="text-xs text-gray-500 mt-2">
|
||
Current: {actionModal.currentReplicas} replicas
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{actionModal.type === 'restart' && (
|
||
<div>
|
||
<p className="text-sm text-gray-500 mb-4">
|
||
Are you sure you want to restart deployment <strong>{actionModal.name}</strong> in namespace <strong>{actionModal.namespace}</strong>?
|
||
</p>
|
||
<p className="text-xs text-gray-400">
|
||
This will trigger a rolling restart of all pods in the deployment.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{actionModal.type === 'delete' && (
|
||
<div>
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||
<p className="text-sm text-gray-700">
|
||
Are you sure you want to delete <strong>{actionModal.resourceType}</strong> <strong>{actionModal.name}</strong> in namespace <strong>{actionModal.namespace}</strong>?
|
||
</p>
|
||
</div>
|
||
<p className="text-xs text-red-600 font-medium">
|
||
This action cannot be undone!
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-6 flex justify-end gap-3">
|
||
<button
|
||
onClick={() => setActionModal(null)}
|
||
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||
disabled={isActioning}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={async () => {
|
||
if (!actionModal) return
|
||
|
||
if (actionModal.type === 'scale') {
|
||
await handleScaleDeployment(actionModal.namespace, actionModal.name, scaleReplicas)
|
||
} else if (actionModal.type === 'restart') {
|
||
await handleRestartDeployment(actionModal.namespace, actionModal.name)
|
||
} else if (actionModal.type === 'delete') {
|
||
await handleDeleteResource(actionModal.resourceType, actionModal.namespace, actionModal.name)
|
||
}
|
||
}}
|
||
disabled={isActioning}
|
||
className={`px-4 py-2 rounded-md text-sm font-medium text-white ${
|
||
actionModal.type === 'delete'
|
||
? 'bg-red-600 hover:bg-red-700'
|
||
: 'bg-blue-600 hover:bg-blue-700'
|
||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||
>
|
||
{isActioning ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
|
||
Processing...
|
||
</>
|
||
) : (
|
||
<>
|
||
{actionModal.type === 'scale' && 'Scale'}
|
||
{actionModal.type === 'restart' && 'Restart'}
|
||
{actionModal.type === 'delete' && 'Delete'}
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* ═══ GLOBAL SEARCH MODAL (Cmd+K) ═══ */}
|
||
{showSearchModal && (
|
||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[12vh]">
|
||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setShowSearchModal(false)} />
|
||
<div className="relative bg-white rounded-xl shadow-2xl max-w-2xl w-[90vw] max-h-[60vh] overflow-hidden z-[101] border border-gray-200">
|
||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200">
|
||
<Search className="w-5 h-5 text-gray-400" />
|
||
<input
|
||
autoFocus
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Search deployments, pods, services, databases, events..."
|
||
className="flex-1 text-sm text-gray-900 placeholder-gray-400 bg-transparent outline-none"
|
||
/>
|
||
<kbd className="hidden sm:inline-flex items-center px-2 py-1 text-xs font-medium text-gray-400 bg-gray-100 border border-gray-200 rounded">ESC</kbd>
|
||
</div>
|
||
<div className="overflow-y-auto max-h-[50vh] p-2">
|
||
{searchQuery.trim() === '' ? (
|
||
<div className="p-8 text-center text-sm text-gray-400">
|
||
<Search className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||
Type to search across all resource types...
|
||
</div>
|
||
) : searchResults.length === 0 ? (
|
||
<div className="p-8 text-center text-sm text-gray-400">No results for "{searchQuery}"</div>
|
||
) : (
|
||
<div className="space-y-0.5">
|
||
{searchResults.map((result, idx) => (
|
||
<button
|
||
key={`${result.type}-${result.name}-${idx}`}
|
||
onClick={() => {
|
||
setActiveResourceTab(result.tabKey as typeof activeResourceTab)
|
||
// Only open detail modal for resource types that support it
|
||
if (result.resourceType !== 'event' && result.resourceType !== 'database') {
|
||
setSelectedResource({ type: result.resourceType as any, namespace: result.namespace, name: result.name })
|
||
}
|
||
setShowSearchModal(false)
|
||
}}
|
||
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg hover:bg-gray-100 text-left transition-colors"
|
||
>
|
||
<span className={`px-2 py-0.5 text-xs font-medium rounded min-w-[80px] text-center ${
|
||
result.type === 'Deployment' ? 'bg-blue-100 text-blue-700' :
|
||
result.type === 'Pod' ? 'bg-emerald-100 text-emerald-700' :
|
||
result.type === 'Service' ? 'bg-purple-100 text-purple-700' :
|
||
result.type === 'ConfigMap' ? 'bg-cyan-100 text-cyan-700' :
|
||
result.type === 'Secret' ? 'bg-amber-100 text-amber-700' :
|
||
result.type === 'CronJob' ? 'bg-orange-100 text-orange-700' :
|
||
result.type === 'Job' ? 'bg-teal-100 text-teal-700' :
|
||
result.type === 'Ingress' ? 'bg-indigo-100 text-indigo-700' :
|
||
result.type === 'PVC' ? 'bg-pink-100 text-pink-700' :
|
||
result.type === 'Node' ? 'bg-slate-100 text-slate-700' :
|
||
result.type === 'Event' ? 'bg-red-100 text-red-700' :
|
||
result.type === 'Database' ? 'bg-violet-100 text-violet-700' :
|
||
'bg-gray-100 text-gray-700'
|
||
}`}>{result.type}</span>
|
||
<span className="font-medium text-gray-900 truncate">{result.name}</span>
|
||
<span className="text-xs text-gray-400 ml-auto flex-shrink-0">{result.namespace}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|