diff --git a/src/App.tsx b/src/App.tsx index 78c0913..adaf0e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { DaemonSetsList } from './components/DaemonSetsList'; import { NamespacesList } from './components/NamespacesList'; import { ConfigMapsList } from './components/ConfigMapsList'; import { SecretsList } from './components/SecretsList'; +import { ServiceAccountsList } from './components/ServiceAccountsList'; import { VisualPreview } from './components/VisualPreview'; import { Footer } from './components/Footer'; import { SocialShare } from './components/SocialShare'; @@ -17,6 +18,7 @@ import { SEOHead } from './components/SEOHead'; import { NamespaceManager } from './components/NamespaceManager'; import { ConfigMapManager } from './components/ConfigMapManager'; import { SecretManager } from './components/SecretManager'; +import { ServiceAccountManager } from './components/ServiceAccountManager'; import { ProjectSettingsManager } from './components/ProjectSettingsManager'; import { YouTubePopup } from './components/YouTubePopup'; import { DockerRunPopup } from './components/DockerRunPopup'; @@ -24,7 +26,7 @@ import { generateMultiDeploymentYaml } from './utils/yamlGenerator'; import { JobManager, Job } from './components/JobManager'; import { JobList } from './components/jobs/JobList'; import { CronJobList } from './components/jobs/CronJobList'; -import type { DeploymentConfig, DaemonSetConfig, Namespace, ConfigMap, Secret, ProjectSettings, JobConfig, CronJobConfig } from './types'; +import type { DeploymentConfig, DaemonSetConfig, Namespace, ConfigMap, Secret, ServiceAccount, ProjectSettings, JobConfig, CronJobConfig } from './types'; import { K8sDeploymentIcon, K8sNamespaceIcon, @@ -33,11 +35,13 @@ import { K8sJobIcon, K8sCronJobIcon, K8sStorageIcon, - K8sDaemonSetIcon + K8sDaemonSetIcon, + K8sSecurityIcon, + K8sServiceAccountIcon } from './components/KubernetesIcons'; type PreviewMode = 'visual' | 'yaml' | 'summary' | 'argocd' | 'flow'; -type SidebarTab = 'deployments' | 'daemonsets' | 'namespaces' | 'storage' | 'jobs' | 'configmaps' | 'secrets'; +type SidebarTab = 'deployments' | 'daemonsets' | 'namespaces' | 'storage' | 'security' | 'jobs' | 'configmaps' | 'secrets'; function App() { const hideDemoIcons = import.meta.env.VITE_HIDE_DEMO_ICONS === 'true'; @@ -62,20 +66,26 @@ function App() { }]); const [configMaps, setConfigMaps] = useState([]); const [secrets, setSecrets] = useState([]); + const [serviceAccounts, setServiceAccounts] = useState([]); const [jobs, setJobs] = useState([]); const [selectedDeployment, setSelectedDeployment] = useState(0); const [selectedDaemonSet, setSelectedDaemonSet] = useState(0); const [selectedNamespace, setSelectedNamespace] = useState(0); const [selectedConfigMap, setSelectedConfigMap] = useState(0); const [selectedSecret, setSelectedSecret] = useState(0); + const [selectedServiceAccount, setSelectedServiceAccount] = useState(0); const [previewMode, setPreviewMode] = useState('flow'); const [sidebarTab, setSidebarTab] = useState('deployments'); + const [showAllResources, setShowAllResources] = useState(true); // Show all resources by default const [storageSubTab, setStorageSubTab] = useState<'configmaps' | 'secrets'>('configmaps'); + const [securitySubTab, setSecuritySubTab] = useState<'serviceaccounts'>('serviceaccounts'); const [jobsSubTab, setJobsSubTab] = useState<'jobs' | 'cronjobs'>('jobs'); const [showForm, setShowForm] = useState(false); const [showNamespaceManager, setShowNamespaceManager] = useState(false); const [showConfigMapManager, setShowConfigMapManager] = useState(false); const [showSecretManager, setShowSecretManager] = useState(false); + const [showServiceAccountManager, setShowServiceAccountManager] = useState(false); + const [editingServiceAccountIndex, setEditingServiceAccountIndex] = useState(undefined); const [showJobManager, setShowJobManager] = useState(false); const [jobTypeToCreate, setJobTypeToCreate] = useState<'job' | 'cronjob'>('job'); const [showProjectSettings, setShowProjectSettings] = useState(false); @@ -84,8 +94,8 @@ function App() { const [showDockerPopup, setShowDockerPopup] = useState(false); const [showUploadModal, setShowUploadModal] = useState(false); const [showClearModal, setShowClearModal] = useState(false); - // Only one group open at a time: 'workloads' | 'storage' - const [openGroup, setOpenGroup] = useState<'workloads' | 'storage'>('workloads'); + // Only one group open at a time: 'workloads' | 'storage' | 'security' | null (all collapsed) + const [openGroup, setOpenGroup] = useState<'workloads' | 'storage' | 'security' | null>(null); const [jobToEdit, setJobToEdit] = useState(undefined); const [selectedJob, setSelectedJob] = useState(-1); const [selectedCronJob, setSelectedCronJob] = useState(-1); @@ -104,6 +114,7 @@ function App() { jobs, configMaps, secrets, + serviceAccounts, namespaces, projectSettings, generatedYaml @@ -118,7 +129,7 @@ function App() { console.warn('Force save failed:', e); return false; } - }, [deployments, daemonSets, jobs, configMaps, secrets, namespaces, projectSettings, generatedYaml]); + }, [deployments, daemonSets, jobs, configMaps, secrets, serviceAccounts, namespaces, projectSettings, generatedYaml]); // Auto-save function const autoSave = useCallback(() => { @@ -134,6 +145,7 @@ function App() { jobs, configMaps, secrets, + serviceAccounts, namespaces, projectSettings, generatedYaml @@ -146,13 +158,13 @@ function App() { console.warn('Auto-save failed:', e); } }, 3000); // 3 second delay - }, [deployments, daemonSets, jobs, configMaps, secrets, namespaces, projectSettings, generatedYaml]); + }, [deployments, daemonSets, jobs, configMaps, secrets, serviceAccounts, namespaces, projectSettings, generatedYaml]); // Update generated YAML when configuration changes useEffect(() => { const yaml = getPreviewYaml(); setGeneratedYaml(yaml); - }, [deployments, daemonSets, jobs, configMaps, secrets, namespaces, projectSettings]); + }, [deployments, daemonSets, jobs, configMaps, secrets, serviceAccounts, namespaces, projectSettings]); // Trigger auto-save when any configuration changes useEffect(() => { @@ -170,6 +182,7 @@ function App() { if (saved.namespaces && saved.namespaces.length > 0) setNamespaces(saved.namespaces); if (saved.configMaps) setConfigMaps(saved.configMaps); if (saved.secrets) setSecrets(saved.secrets); + if (saved.serviceAccounts) setServiceAccounts(saved.serviceAccounts); if (saved.jobs) setJobs(saved.jobs); if (saved.generatedYaml) setGeneratedYaml(saved.generatedYaml); console.log('Configuration loaded from localStorage'); @@ -215,6 +228,7 @@ function App() { secrets: [], selectedConfigMaps: [], selectedSecrets: [], + serviceAccount: undefined, ingress: { enabled: false, className: '', @@ -249,6 +263,7 @@ function App() { secrets: [], selectedConfigMaps: [], selectedSecrets: [], + serviceAccount: undefined, nodeSelector: {} }; @@ -326,6 +341,7 @@ function App() { secrets: [], selectedConfigMaps: [], selectedSecrets: [], + serviceAccount: undefined, ingress: { enabled: false, className: '', @@ -533,6 +549,110 @@ function App() { setSelectedSecret(secrets.length); }; + // Service Account management functions + const handleAddServiceAccount = (serviceAccount: ServiceAccount) => { + const serviceAccountWithGlobalLabels: ServiceAccount = { + ...serviceAccount, + labels: cleanAndMergeLabels(serviceAccount.labels) + }; + setServiceAccounts([...serviceAccounts, serviceAccountWithGlobalLabels]); + setSelectedServiceAccount(serviceAccounts.length); + setShowServiceAccountManager(false); + setEditingServiceAccountIndex(undefined); + }; + + const handleUpdateServiceAccount = (serviceAccount: ServiceAccount, index: number) => { + const oldServiceAccount = serviceAccounts[index]; + const serviceAccountWithGlobalLabels: ServiceAccount = { + ...serviceAccount, + labels: cleanAndMergeLabels(serviceAccount.labels) + }; + const newServiceAccounts = [...serviceAccounts]; + newServiceAccounts[index] = serviceAccountWithGlobalLabels; + setServiceAccounts(newServiceAccounts); + + // Update deployments that reference this service account + const updatedDeployments = deployments.map(deployment => { + if (deployment.serviceAccount === oldServiceAccount.name) { + return { + ...deployment, + serviceAccount: serviceAccount.name // Update to new name + }; + } + return deployment; + }); + setDeployments(updatedDeployments); + + // Update daemonSets that reference this service account + const updatedDaemonSets = daemonSets.map(daemonSet => { + if (daemonSet.serviceAccount === oldServiceAccount.name) { + return { + ...daemonSet, + serviceAccount: serviceAccount.name // Update to new name + }; + } + return daemonSet; + }); + setDaemonSets(updatedDaemonSets); + + setShowServiceAccountManager(false); + setEditingServiceAccountIndex(undefined); + }; + + const handleDeleteServiceAccount = (serviceAccountName: string) => { + const index = serviceAccounts.findIndex(sa => sa.name === serviceAccountName); + if (index > -1) { + const newServiceAccounts = serviceAccounts.filter((_, i) => i !== index); + setServiceAccounts(newServiceAccounts); + + // Remove service account reference from deployments that use it + const updatedDeployments = deployments.map(deployment => { + if (deployment.serviceAccount === serviceAccountName) { + return { + ...deployment, + serviceAccount: undefined // Clear the reference + }; + } + return deployment; + }); + setDeployments(updatedDeployments); + + // Remove service account reference from daemonSets that use it + const updatedDaemonSets = daemonSets.map(daemonSet => { + if (daemonSet.serviceAccount === serviceAccountName) { + return { + ...daemonSet, + serviceAccount: undefined // Clear the reference + }; + } + return daemonSet; + }); + setDaemonSets(updatedDaemonSets); + + if (selectedServiceAccount >= newServiceAccounts.length) { + setSelectedServiceAccount(Math.max(0, newServiceAccounts.length - 1)); + } + } + }; + + const handleDuplicateServiceAccount = (index: number) => { + const serviceAccountToDuplicate = serviceAccounts[index]; + const duplicatedServiceAccount: ServiceAccount = { + ...serviceAccountToDuplicate, + name: `${serviceAccountToDuplicate.name}-copy`, + createdAt: new Date().toISOString() + }; + setServiceAccounts([...serviceAccounts, duplicatedServiceAccount]); + setSelectedServiceAccount(serviceAccounts.length); + setEditingServiceAccountIndex(serviceAccounts.length); + setShowServiceAccountManager(true); + }; + + const handleEditServiceAccount = (index: number) => { + setEditingServiceAccountIndex(index); + setShowServiceAccountManager(true); + }; + // Job management functions const handleAddJob = (job: Job) => { // Convert job labels from array to object format for global label merging @@ -614,6 +734,12 @@ function App() { })); setSecrets(updatedSecrets); + const updatedServiceAccounts = serviceAccounts.map(serviceAccount => ({ + ...serviceAccount, + labels: cleanAndMergeLabels(serviceAccount.labels, oldGlobalLabels, newSettings.globalLabels, newSettings.name) + })); + setServiceAccounts(updatedServiceAccounts); + // Update jobs with new global labels const updatedJobs = jobs.map(job => { // Convert job labels from array to object format @@ -670,7 +796,7 @@ function App() { // Fix: Only map regular jobs to jobConfigs, not cronjobs const jobConfigs = jobs.filter(j => j.type === 'job').map(jobToJobConfig); const cronJobConfigs = jobs.filter(j => j.type === 'cronjob').map(jobToCronJobConfig); - const yaml = generateMultiDeploymentYaml(validDeployments, namespaces, configMaps, secrets, projectSettings, jobConfigs, cronJobConfigs, validDaemonSets); + const yaml = generateMultiDeploymentYaml(validDeployments, namespaces, configMaps, secrets, projectSettings, jobConfigs, cronJobConfigs, validDaemonSets, serviceAccounts); let finalYaml = yaml; if ( @@ -680,9 +806,10 @@ function App() { cronJobConfigs.length === 0 && namespaces.length <= 1 && configMaps.length === 0 && - secrets.length === 0 + secrets.length === 0 && + serviceAccounts.length === 0 ) { - finalYaml = '# No deployments, daemonsets, or jobs configured\n# Create your first deployment, daemonset, or job to see the generated YAML'; + finalYaml = '# No resources configured\n# Create your first deployment, daemonset, job, service account, configmap, or secret to see the generated YAML'; } return finalYaml; @@ -705,14 +832,7 @@ function App() { } }, []); - useEffect(() => { - // Ensure the correct group is open based on the selected tab - if (sidebarTab === 'storage' || sidebarTab === 'configmaps' || sidebarTab === 'secrets') { - setOpenGroup('storage'); - } else { - setOpenGroup('workloads'); - } - }, [sidebarTab]); + // Helper: Map Job (JobManager) to JobConfig (for JobList) function jobToJobConfig(job: Job): JobConfig { @@ -748,22 +868,47 @@ function App() { }; } + // Function to determine filter type based on current sidebar tab and sub-tabs + const getFilterType = (): 'all' | 'deployments' | 'daemonsets' | 'namespaces' | 'configmaps' | 'secrets' | 'serviceaccounts' | 'jobs' | 'cronjobs' => { + // Show all resources when showAllResources is true + if (showAllResources) return 'all'; + + // Show specific resources based on sidebar tab + if (sidebarTab === 'deployments') return 'deployments'; + if (sidebarTab === 'daemonsets') return 'daemonsets'; + if (sidebarTab === 'namespaces') return 'namespaces'; + if (sidebarTab === 'jobs') { + if (jobsSubTab === 'jobs') return 'jobs'; + if (jobsSubTab === 'cronjobs') return 'cronjobs'; + return 'jobs'; // default + } + if (sidebarTab === 'storage') { + if (storageSubTab === 'configmaps') return 'configmaps'; + if (storageSubTab === 'secrets') return 'secrets'; + return 'configmaps'; // default + } + if (sidebarTab === 'security') { + if (securitySubTab === 'serviceaccounts') return 'serviceaccounts'; + return 'serviceaccounts'; // default + } + return 'all'; // Show all resources by default + }; + // Function to handle menu item clicks and set appropriate preview mode const handleMenuClick = (tab: SidebarTab, subTab?: string) => { setSidebarTab(tab); + setShowAllResources(false); // Show filtered view when clicking menu items - // Set preview mode based on the selected tab - if (tab === 'deployments' || tab === 'daemonsets') { - setPreviewMode('flow'); - } else { - setPreviewMode('yaml'); - } + // Set visual mode as default for all items + setPreviewMode('flow'); // Handle sub-tabs if (subTab === 'configmaps') { setStorageSubTab('configmaps'); } else if (subTab === 'secrets') { setStorageSubTab('secrets'); + } else if (subTab === 'serviceaccounts') { + setSecuritySubTab('serviceaccounts'); } else if (subTab === 'jobs') { setJobsSubTab('jobs'); } else if (subTab === 'cronjobs') { @@ -814,6 +959,7 @@ function App() { secrets: [], selectedConfigMaps: [], selectedSecrets: [], + serviceAccount: undefined, nodeSelector: {} }; setDaemonSets([...daemonSets, newDaemonSet]); @@ -895,14 +1041,22 @@ function App() {
- { + setShowAllResources(true); + setSidebarTab('deployments'); // Reset to default tab + }} + className="text-lg sm:text-xl font-semibold text-gray-900 hover:text-blue-600 transition-colors duration-200 text-left" > Kube Composer - +

{projectSettings.name ? `Project: ${projectSettings.name}` : 'Kubernetes YAML Generator for developers'} + {showAllResources && ( + + All Resources + + )}

@@ -1069,7 +1223,14 @@ function App() { {/* Workloads Group */} )} + + {/* Security Group */} + + {openGroup === 'security' && ( +
+ + + {/* Future RBAC items - disabled for now */} + + + + + + + +
+ )} {/* Tab Content */} -
+
{sidebarTab === 'deployments' && (
@@ -1438,6 +1678,38 @@ function App() { )} )} + + {sidebarTab === 'security' && ( + <> + {securitySubTab === 'serviceaccounts' && ( + <> +
+ +
+ { + setSelectedServiceAccount(index); + setSidebarOpen(false); + }} + onEdit={handleEditServiceAccount} + onDelete={handleDeleteServiceAccount} + onDuplicate={handleDuplicateServiceAccount} + /> + + )} + + )}
@@ -1483,6 +1755,7 @@ function App() { {secrets.length} secret{secrets.length !== 1 ? 's' : ''}
+
{jobs.filter(j => j.type === 'job').length} @@ -1519,8 +1792,8 @@ function App() {
- {previewMode === 'flow' && } - {previewMode === 'summary' && } + {previewMode === 'flow' && } + {previewMode === 'summary' && } {previewMode === 'yaml' && }
@@ -1580,6 +1853,13 @@ function App() { availableNamespaces={availableNamespaces} availableConfigMaps={configMaps} availableSecrets={secrets} + availableServiceAccounts={serviceAccounts} + onNavigateToServiceAccounts={() => { + setShowForm(false); + setSidebarTab('security'); + setSecuritySubTab('serviceaccounts'); + setShowServiceAccountManager(true); + }} /> )} @@ -1635,6 +1915,22 @@ function App() { /> )} + {/* Service Account Manager Modal */} + {showServiceAccountManager && ( + { + setShowServiceAccountManager(false); + setEditingServiceAccountIndex(undefined); + }} + editingIndex={editingServiceAccountIndex} + /> + )} + {/* Job Manager Modal */} {showJobManager && (

- This action will permanently remove all your deployments, daemonsets, jobs, configmaps, secrets, and namespaces. This action cannot be undone. + This action will permanently remove all your deployments, daemonsets, jobs, configmaps, secrets, service accounts, and namespaces. This action cannot be undone.

{/* Configuration Summary */} @@ -1790,6 +2086,10 @@ function App() { Namespaces: {namespaces.length} +
+ Service Accounts: + {serviceAccounts.length} +
@@ -1817,6 +2117,7 @@ function App() { setJobs([]); setConfigMaps([]); setSecrets([]); + setServiceAccounts([]); setNamespaces([{ name: 'default', labels: {}, @@ -1836,6 +2137,8 @@ function App() { setSelectedNamespace(0); setSelectedConfigMap(0); setSelectedSecret(0); + setSelectedServiceAccount(0); + setEditingServiceAccountIndex(undefined); setSelectedJob(-1); setSelectedCronJob(-1); diff --git a/src/components/DeploymentForm.tsx b/src/components/DeploymentForm.tsx index ddff1b0..a3c58c6 100644 --- a/src/components/DeploymentForm.tsx +++ b/src/components/DeploymentForm.tsx @@ -1,5 +1,5 @@ -import { Plus, Minus, Server, Settings, Database, Key, Trash2, Copy, Globe, Shield, FileText } from 'lucide-react'; -import type { DeploymentConfig, Container, ConfigMap, Secret, EnvVar } from '../types'; +import { Plus, Minus, Server, Settings, Database, Key, Trash2, Copy, Globe, Shield, FileText, Users, X } from 'lucide-react'; +import type { DeploymentConfig, Container, ConfigMap, Secret, EnvVar, ServiceAccount } from '../types'; interface DeploymentFormProps { config: DeploymentConfig; @@ -7,9 +7,11 @@ interface DeploymentFormProps { availableNamespaces: string[]; availableConfigMaps: ConfigMap[]; availableSecrets: Secret[]; + availableServiceAccounts: ServiceAccount[]; + onNavigateToServiceAccounts?: () => void; } -export function DeploymentForm({ config, onChange, availableNamespaces, availableConfigMaps, availableSecrets }: DeploymentFormProps) { +export function DeploymentForm({ config, onChange, availableNamespaces, availableConfigMaps, availableSecrets, availableServiceAccounts, onNavigateToServiceAccounts }: DeploymentFormProps) { const updateConfig = (updates: Partial) => { onChange({ ...config, ...updates }); }; @@ -330,6 +332,56 @@ export function DeploymentForm({ config, onChange, availableNamespaces, availabl ))} + +
+
+ + {onNavigateToServiceAccounts && ( + + )} +
+
+ + {config.serviceAccount && ( + + )} +
+ {config.serviceAccount && ( +
+ + Using service account: {config.serviceAccount} +
+ )} +
diff --git a/src/components/KubernetesIcons.tsx b/src/components/KubernetesIcons.tsx index 052ea04..5674c5f 100644 --- a/src/components/KubernetesIcons.tsx +++ b/src/components/KubernetesIcons.tsx @@ -56,4 +56,16 @@ export const K8sPodIcon: React.FC = ({ className = "w-4 h-4" }) => ( +); + +export const K8sSecurityIcon: React.FC = ({ className = "w-4 h-4" }) => ( + + + +); + +export const K8sServiceAccountIcon: React.FC = ({ className = "w-4 h-4" }) => ( + + + ); \ No newline at end of file diff --git a/src/components/ResourceSummary.tsx b/src/components/ResourceSummary.tsx index 6c12180..2bb25c2 100644 --- a/src/components/ResourceSummary.tsx +++ b/src/components/ResourceSummary.tsx @@ -1,50 +1,109 @@ -import { Server, Globe, Database, CheckCircle, AlertCircle, Shield } from 'lucide-react'; -import type { DeploymentConfig } from '../types'; +import { Server, CheckCircle, AlertCircle, Users, Settings, Key, Play, Clock } from 'lucide-react'; +import type { DeploymentConfig, DaemonSetConfig, Namespace, ConfigMap, Secret, ServiceAccount } from '../types'; +import type { Job } from './JobManager'; interface ResourceSummaryProps { - config: DeploymentConfig; + deployments: DeploymentConfig[]; + daemonSets: DaemonSetConfig[]; + namespaces: Namespace[]; + configMaps: ConfigMap[]; + secrets: Secret[]; + serviceAccounts: ServiceAccount[]; + jobs: Job[]; } -export function ResourceSummary({ config }: ResourceSummaryProps) { - const getResourceCount = () => { +export function ResourceSummary({ + deployments, + daemonSets, + namespaces, + configMaps, + secrets, + serviceAccounts, + jobs +}: ResourceSummaryProps) { + const getTotalResourceCount = () => { let count = 0; - if (config.appName) count += 2; // Deployment + Service - if (config.ingress.enabled) count += 1; // Ingress - count += config.configMaps.length; - count += config.secrets.length; + + // Deployments and their associated resources + deployments.forEach(deployment => { + if (deployment.appName) { + count += 2; // Deployment + Service + if (deployment.ingress?.enabled) count += 1; // Ingress + } + }); + + // DaemonSets and their associated resources + daemonSets.forEach(daemonSet => { + if (daemonSet.appName) { + count += 1; // DaemonSet + if (daemonSet.serviceEnabled) count += 1; // Service + } + }); + + // Other resources + count += configMaps.length; + count += secrets.length; + count += serviceAccounts.length; + count += namespaces.length; + count += jobs.length; + return count; }; const getValidationStatus = () => { - const issues = []; - if (!config.appName) issues.push('Application name is required'); + const issues: string[] = []; + + // Check deployments + deployments.forEach((deployment, index) => { + if (!deployment.appName) issues.push(`Deployment ${index + 1}: Application name is required`); + + if (!deployment.containers || deployment.containers.length === 0) { + issues.push(`Deployment ${index + 1}: At least one container is required`); + } else { + deployment.containers.forEach((container, containerIndex) => { + if (!container.name) issues.push(`Deployment ${index + 1}, Container ${containerIndex + 1}: Name is required`); + if (!container.image) issues.push(`Deployment ${index + 1}, Container ${containerIndex + 1}: Image is required`); + }); + } + + if (deployment.port <= 0) issues.push(`Deployment ${index + 1}: Service port must be greater than 0`); + if (deployment.targetPort <= 0) issues.push(`Deployment ${index + 1}: Target port must be greater than 0`); + if (deployment.replicas <= 0) issues.push(`Deployment ${index + 1}: Replicas must be greater than 0`); + }); - // Check containers - if (!config.containers || config.containers.length === 0) { - issues.push('At least one container is required'); - } else { - config.containers.forEach((container, index) => { - if (!container.name) issues.push(`Container ${index + 1}: Name is required`); - if (!container.image) issues.push(`Container ${index + 1}: Image is required`); - if (container.port && container.port <= 0) issues.push(`Container ${index + 1}: Port must be greater than 0`); - }); - } + // Check daemonSets + daemonSets.forEach((daemonSet, index) => { + if (!daemonSet.appName) issues.push(`DaemonSet ${index + 1}: Application name is required`); + + if (!daemonSet.containers || daemonSet.containers.length === 0) { + issues.push(`DaemonSet ${index + 1}: At least one container is required`); + } else { + daemonSet.containers.forEach((container, containerIndex) => { + if (!container.name) issues.push(`DaemonSet ${index + 1}, Container ${containerIndex + 1}: Name is required`); + if (!container.image) issues.push(`DaemonSet ${index + 1}, Container ${containerIndex + 1}: Image is required`); + }); + } + }); - if (config.port <= 0) issues.push('Service port must be greater than 0'); - if (config.targetPort <= 0) issues.push('Target port must be greater than 0'); - if (config.replicas <= 0) issues.push('Replicas must be greater than 0'); + // Check service accounts + serviceAccounts.forEach((serviceAccount, index) => { + if (!serviceAccount.name) issues.push(`Service Account ${index + 1}: Name is required`); + if (!serviceAccount.namespace) issues.push(`Service Account ${index + 1}: Namespace is required`); + }); - // Check ingress configuration - if (config.ingress.enabled) { - if (config.ingress.rules.length === 0) { - issues.push('Ingress is enabled but no rules are configured'); + // Check jobs + jobs.forEach((job, index) => { + if (!job.name) issues.push(`Job ${index + 1}: Name is required`); + if (!job.namespace) issues.push(`Job ${index + 1}: Namespace is required`); + if (!job.containers || job.containers.length === 0) { + issues.push(`Job ${index + 1}: At least one container is required`); } else { - config.ingress.rules.forEach((rule, index) => { - if (!rule.serviceName) issues.push(`Ingress rule ${index + 1}: Service name is required`); - if (rule.servicePort <= 0) issues.push(`Ingress rule ${index + 1}: Service port must be greater than 0`); + job.containers.forEach((container, containerIndex) => { + if (!container.name) issues.push(`Job ${index + 1}, Container ${containerIndex + 1}: Name is required`); + if (!container.image) issues.push(`Job ${index + 1}, Container ${containerIndex + 1}: Image is required`); }); } - } + }); return { isValid: issues.length === 0, @@ -53,7 +112,11 @@ export function ResourceSummary({ config }: ResourceSummaryProps) { }; const validation = getValidationStatus(); - const containerCount = config.containers?.length || 0; + const totalResources = getTotalResourceCount(); + const validDeployments = deployments.filter(d => d.appName); + const validDaemonSets = daemonSets.filter(d => d.appName); + const validServiceAccounts = serviceAccounts.filter(sa => sa.name); + const validJobs = jobs.filter(job => job.name); return (
@@ -87,88 +150,107 @@ export function ResourceSummary({ config }: ResourceSummaryProps) {
{/* Resource Overview */} -
+
+ {/* Workloads */}
- Deployment + Workloads
-
Name: {config.appName || 'Not specified'}
-
Containers: {containerCount}
-
Replicas: {config.replicas}
-
Namespace: {config.namespace}
+
Deployments: {validDeployments.length}
+
DaemonSets: {validDaemonSets.length}
+
Jobs: {validJobs.filter(job => job.type === 'job').length}
+
CronJobs: {validJobs.filter(job => job.type === 'cronjob').length}
+
Total Containers: {validDeployments.reduce((sum, d) => sum + (d.containers?.length || 0), 0) + validDaemonSets.reduce((sum, d) => sum + (d.containers?.length || 0), 0) + validJobs.reduce((sum, job) => sum + (job.containers?.length || 0), 0)}
+ {/* Security */} +
+
+ + Security +
+
+
Service Accounts: {validServiceAccounts.length}
+
Secrets: {secrets.length}
+
Total Secrets: {validServiceAccounts.reduce((sum, sa) => sum + (sa.secrets?.length || 0) + (sa.imagePullSecrets?.length || 0), 0)}
+
+
+ + {/* Storage */}
- - Service + + Storage
-
Type: {config.serviceType}
-
Port: {config.port}
-
Target Port: {config.targetPort}
-
Protocol: TCP
+
ConfigMaps: {configMaps.length}
+
Namespaces: {namespaces.length}
+
Total Data Keys: {configMaps.reduce((sum, cm) => sum + Object.keys(cm.data).length, 0)}
- {/* Ingress Summary */} - {config.ingress.enabled && ( -
-
- - Ingress -
-
-
Rules: {config.ingress.rules.length}
-
TLS Certificates: {config.ingress.tls.length}
- {config.ingress.className && ( -
Ingress Class: {config.ingress.className}
- )} - {config.ingress.rules.length > 0 && ( -
-
Configured Hosts:
- {config.ingress.rules.map((rule, index) => ( -
- {rule.host || '*'} → {rule.path} → {rule.serviceName}:{rule.servicePort} -
- ))} + {/* Service Accounts Summary */} + {validServiceAccounts.length > 0 && ( +
+

Service Accounts

+
+ {validServiceAccounts.map((serviceAccount, index) => ( +
+
+ + {serviceAccount.name} +
+
+
Namespace: {serviceAccount.namespace}
+
Secrets: {serviceAccount.secrets?.length || 0}
+
Image Pull Secrets: {serviceAccount.imagePullSecrets?.length || 0}
+ {serviceAccount.automountServiceAccountToken !== undefined && ( +
Auto-mount: {serviceAccount.automountServiceAccountToken ? 'Enabled' : 'Disabled'}
+ )} +
- )} + ))}
)} - {/* Container Details */} - {config.containers && config.containers.length > 0 && ( + {/* Jobs Summary */} + {validJobs.length > 0 && (
-

Container Details

-
- {config.containers.map((container, index) => ( -
+

Jobs & CronJobs

+
+ {validJobs.map((job, index) => ( +
- - - Container {index + 1}: {container.name || 'Unnamed'} - + {job.type === 'cronjob' ? ( + + ) : ( + + )} + {job.name}
-
-
Image: {container.image || 'Not specified'}
-
Port: {container.port}
- {container.env.length > 0 && ( -
Environment Variables: {container.env.length}
+
+
Type: {job.type === 'cronjob' ? 'CronJob' : 'Job'}
+
Namespace: {job.namespace}
+
Containers: {job.containers?.length || 0}
+ {job.type === 'cronjob' && job.schedule && ( +
Schedule: {job.schedule}
)} - {container.volumeMounts.length > 0 && ( -
Volume Mounts: {container.volumeMounts.length}
+ {job.completions && ( +
Completions: {job.completions}
)} - {(container.resources.requests.cpu || container.resources.requests.memory) && ( -
Resource Requests: - {container.resources.requests.cpu && ` CPU: ${container.resources.requests.cpu}`} - {container.resources.requests.memory && ` Memory: ${container.resources.requests.memory}`} -
+ {job.replicas && ( +
Parallelism: {job.replicas}
)}
@@ -177,53 +259,94 @@ export function ResourceSummary({ config }: ResourceSummaryProps) {
)} - {/* Additional Resources */} - {config.volumes.length > 0 && ( + {/* Deployments Summary */} + {validDeployments.length > 0 && (
-

Additional Resources

- -
-
- - Volumes -
-
- {config.volumes.length} volume{config.volumes.length !== 1 ? 's' : ''} configured -
-
- {config.volumes.slice(0, 3).map((volume, index) => ( -
- {volume.name} → {volume.mountPath} ({volume.type}) +

Deployments

+
+ {validDeployments.map((deployment, index) => ( +
+
+ + {deployment.appName}
- ))} - {config.volumes.length > 3 && ( -
- +{config.volumes.length - 3} more... +
+
Namespace: {deployment.namespace}
+
Replicas: {deployment.replicas}
+
Containers: {deployment.containers?.length || 0}
+
Port: {deployment.port} → {deployment.targetPort}
+ {deployment.ingress?.enabled && ( +
Ingress: Enabled ({deployment.ingress.rules.length} rules)
+ )}
- )} -
+
+ ))}
)} - {/* Security Features */} - {config.ingress.enabled && config.ingress.tls.length > 0 && ( -
-
- - Security Features + {/* DaemonSets Summary */} + {validDaemonSets.length > 0 && ( +
+

DaemonSets

+
+ {validDaemonSets.map((daemonSet, index) => ( +
+
+ + {daemonSet.appName} +
+
+
Namespace: {daemonSet.namespace}
+
Containers: {daemonSet.containers?.length || 0}
+
Port: {daemonSet.port} → {daemonSet.targetPort}
+
Service: {daemonSet.serviceEnabled ? 'Enabled' : 'Disabled'}
+
+
+ ))}
-
-
TLS/SSL Encryption: Enabled
-
TLS Certificates: {config.ingress.tls.length}
-
-
Protected Domains:
- {config.ingress.tls.map((tls, index) => ( -
- {tls.secretName}: {tls.hosts.join(', ')} +
+ )} + + {/* ConfigMaps Summary */} + {configMaps.length > 0 && ( +
+

ConfigMaps

+
+ {configMaps.map((configMap, index) => ( +
+
+ + {configMap.name}
- ))} -
+
+
Namespace: {configMap.namespace}
+
Data Keys: {Object.keys(configMap.data).length}
+
+
+ ))} +
+
+ )} + + {/* Secrets Summary */} + {secrets.length > 0 && ( +
+

Secrets

+
+ {secrets.map((secret, index) => ( +
+
+ + {secret.name} +
+
+
Namespace: {secret.namespace}
+
Data Keys: {Object.keys(secret.data).length}
+
Type: {secret.type}
+
+
+ ))}
)} @@ -232,7 +355,7 @@ export function ResourceSummary({ config }: ResourceSummaryProps) {
Total Kubernetes Resources - {getResourceCount()} + {totalResources}
Resources that will be created in your cluster diff --git a/src/components/ServiceAccountManager.tsx b/src/components/ServiceAccountManager.tsx new file mode 100644 index 0000000..665bbd0 --- /dev/null +++ b/src/components/ServiceAccountManager.tsx @@ -0,0 +1,556 @@ +import React, { useState, useEffect } from 'react'; +import { X, Plus, Trash2, AlertTriangle, Settings, Users, Key } from 'lucide-react'; +import type { ServiceAccount, Secret } from '../types'; + +interface ServiceAccountManagerProps { + serviceAccounts: ServiceAccount[]; + namespaces: string[]; + secrets: Secret[]; + onAddServiceAccount: (serviceAccount: ServiceAccount) => void; + onUpdateServiceAccount: (serviceAccount: ServiceAccount, index: number) => void; + onClose: () => void; + editingIndex?: number; +} + +export function ServiceAccountManager({ + serviceAccounts, + namespaces, + secrets, + onAddServiceAccount, + onUpdateServiceAccount, + onClose, + editingIndex +}: ServiceAccountManagerProps) { + const [name, setName] = useState(''); + const [namespace, setNamespace] = useState('default'); + const [labels, setLabels] = useState>([]); + const [annotations, setAnnotations] = useState>([]); + const [selectedSecrets, setSelectedSecrets] = useState>([]); + const [selectedImagePullSecrets, setSelectedImagePullSecrets] = useState>([]); + const [automountServiceAccountToken, setAutomountServiceAccountToken] = useState(true); + const [errors, setErrors] = useState>({}); + + const isEditing = editingIndex !== undefined; + + useEffect(() => { + if (isEditing && serviceAccounts[editingIndex]) { + const sa = serviceAccounts[editingIndex]; + setName(sa.name); + setNamespace(sa.namespace); + setLabels(Object.entries(sa.labels).map(([key, value]) => ({ key, value }))); + setAnnotations(Object.entries(sa.annotations).map(([key, value]) => ({ key, value }))); + setSelectedSecrets(sa.secrets || []); + setSelectedImagePullSecrets(sa.imagePullSecrets || []); + setAutomountServiceAccountToken(sa.automountServiceAccountToken !== false); + } + }, [isEditing, editingIndex, serviceAccounts]); + + const validateForm = () => { + const newErrors: Record = {}; + + if (!name.trim()) { + newErrors.name = 'Service Account name is required'; + } else if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name)) { + newErrors.name = 'Invalid format. Use lowercase letters, numbers, and hyphens.'; + } else if (name.length > 253) { + newErrors.name = 'Service Account name must be 253 characters or less'; + } + + if (!namespace.trim()) { + newErrors.namespace = 'Namespace is required'; + } else if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(namespace)) { + newErrors.namespace = 'Invalid namespace format. Use lowercase letters, numbers, and hyphens.'; + } + + // Check for duplicate service account name in the same namespace + const isDuplicate = serviceAccounts.some((sa, index) => + sa.name === name && + sa.namespace === namespace && + (!isEditing || index !== editingIndex) + ); + + if (isDuplicate) { + newErrors.name = 'A Service Account with this name already exists in this namespace'; + } + + // Validate labels + labels.forEach((label, index) => { + if (label.key && !label.value) { + newErrors[`label-${index}`] = 'Label value is required when key is provided'; + } + if (label.key && !/^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/.test(label.key)) { + newErrors[`label-${index}`] = 'Invalid label key format'; + } + }); + + // Validate annotations + annotations.forEach((annotation, index) => { + if (annotation.key && !annotation.value) { + newErrors[`annotation-${index}`] = 'Annotation value is required when key is provided'; + } + }); + + // Validate secrets + selectedSecrets.forEach((secret, index) => { + if (!secret.name.trim()) { + newErrors[`secret-${index}`] = 'Secret name is required'; + } + }); + + // Validate image pull secrets + selectedImagePullSecrets.forEach((secret, index) => { + if (!secret.name.trim()) { + newErrors[`imagePullSecret-${index}`] = 'Image pull secret name is required'; + } + }); + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + const labelsObj = labels.reduce((acc, { key, value }) => { + if (key && value) acc[key] = value; + return acc; + }, {} as Record); + + const annotationsObj = annotations.reduce((acc, { key, value }) => { + if (key && value) acc[key] = value; + return acc; + }, {} as Record); + + const serviceAccount: ServiceAccount = { + name: name.trim(), + namespace, + labels: labelsObj, + annotations: annotationsObj, + secrets: selectedSecrets.length > 0 ? selectedSecrets : undefined, + imagePullSecrets: selectedImagePullSecrets.length > 0 ? selectedImagePullSecrets : undefined, + automountServiceAccountToken, + createdAt: isEditing ? serviceAccounts[editingIndex!].createdAt : new Date().toISOString() + }; + + if (isEditing) { + onUpdateServiceAccount(serviceAccount, editingIndex!); + } else { + onAddServiceAccount(serviceAccount); + } + onClose(); + }; + + const addLabel = () => { + setLabels([...labels, { key: '', value: '' }]); + }; + + const updateLabel = (index: number, field: 'key' | 'value', value: string) => { + const newLabels = [...labels]; + newLabels[index][field] = value; + setLabels(newLabels); + }; + + const removeLabel = (index: number) => { + setLabels(labels.filter((_, i) => i !== index)); + }; + + const addAnnotation = () => { + setAnnotations([...annotations, { key: '', value: '' }]); + }; + + const updateAnnotation = (index: number, field: 'key' | 'value', value: string) => { + const newAnnotations = [...annotations]; + newAnnotations[index][field] = value; + setAnnotations(newAnnotations); + }; + + const removeAnnotation = (index: number) => { + setAnnotations(annotations.filter((_, i) => i !== index)); + }; + + const addSecret = () => { + setSelectedSecrets([...selectedSecrets, { name: '' }]); + }; + + const updateSecret = (index: number, value: string) => { + const newSecrets = [...selectedSecrets]; + newSecrets[index].name = value; + setSelectedSecrets(newSecrets); + }; + + const removeSecret = (index: number) => { + setSelectedSecrets(selectedSecrets.filter((_, i) => i !== index)); + }; + + const addImagePullSecret = () => { + setSelectedImagePullSecrets([...selectedImagePullSecrets, { name: '' }]); + }; + + const updateImagePullSecret = (index: number, value: string) => { + const newImagePullSecrets = [...selectedImagePullSecrets]; + newImagePullSecrets[index].name = value; + setSelectedImagePullSecrets(newImagePullSecrets); + }; + + const removeImagePullSecret = (index: number) => { + setSelectedImagePullSecrets(selectedImagePullSecrets.filter((_, i) => i !== index)); + }; + + const availableSecrets = secrets.filter(s => s.namespace === namespace); + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ {isEditing ? 'Edit Service Account' : 'Create Service Account'} +

+

+ {isEditing ? 'Update service account configuration' : 'Configure authentication for your applications'} +

+
+
+ +
+ + {/* Content */} +
+
+ {/* Basic Information */} +
+

+ + Basic Information +

+ +
+
+ + setName(e.target.value)} + className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 ${ + errors.name ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} + placeholder="my-service-account" + /> + {errors.name && ( +

+ + {errors.name} +

+ )} +
+ +
+ + + {errors.namespace && ( +

+ + {errors.namespace} +

+ )} +
+
+
+ + {/* Service Account Settings */} +
+

+ + Service Account Settings +

+ +
+ setAutomountServiceAccountToken(e.target.checked)} + className="h-4 w-4 text-cyan-600 focus:ring-cyan-500 border-gray-300 rounded" + /> + +
+

+ When enabled, the service account token will be automatically mounted into pods +

+
+ + {/* Secrets */} +
+
+

Secrets

+ +
+ + {selectedSecrets.map((secret, index) => ( +
+
+ + +
+ {errors[`secret-${index}`] && ( +

+ + {errors[`secret-${index}`]} +

+ )} +
+ ))} + + {selectedSecrets.length === 0 && ( +

No secrets configured

+ )} +
+ + {/* Image Pull Secrets */} +
+
+

Image Pull Secrets

+ +
+ + {selectedImagePullSecrets.map((secret, index) => ( +
+
+ + +
+ {errors[`imagePullSecret-${index}`] && ( +

+ + {errors[`imagePullSecret-${index}`]} +

+ )} +
+ ))} + + {selectedImagePullSecrets.length === 0 && ( +

No image pull secrets configured

+ )} +
+ + {/* Labels */} +
+
+

Labels

+ +
+ + {labels.map((label, index) => ( +
+
+ updateLabel(index, 'key', e.target.value)} + className={`flex-1 px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 ${ + errors[`label-${index}`] ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} + /> + updateLabel(index, 'value', e.target.value)} + className={`flex-1 px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 ${ + errors[`label-${index}`] ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} + /> + +
+ {errors[`label-${index}`] && ( +

+ + {errors[`label-${index}`]} +

+ )} +
+ ))} + + {labels.length === 0 && ( +

No labels configured

+ )} +
+ + {/* Annotations */} +
+
+

Annotations

+ +
+ + {annotations.map((annotation, index) => ( +
+
+ updateAnnotation(index, 'key', e.target.value)} + className={`flex-1 px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 ${ + errors[`annotation-${index}`] ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} + /> + updateAnnotation(index, 'value', e.target.value)} + className={`flex-1 px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 ${ + errors[`annotation-${index}`] ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} + /> + +
+ {errors[`annotation-${index}`] && ( +

+ + {errors[`annotation-${index}`]} +

+ )} +
+ ))} + + {annotations.length === 0 && ( +

No annotations configured

+ )} +
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ServiceAccountsList.tsx b/src/components/ServiceAccountsList.tsx new file mode 100644 index 0000000..8e357df --- /dev/null +++ b/src/components/ServiceAccountsList.tsx @@ -0,0 +1,203 @@ +import React, { useState } from 'react'; +import { Settings, Copy, Trash2, AlertTriangle, Calendar, Users, Key } from 'lucide-react'; +import { K8sServiceAccountIcon } from './KubernetesIcons'; +import type { ServiceAccount } from '../types'; + +interface ServiceAccountsListProps { + serviceAccounts: ServiceAccount[]; + selectedIndex: number; + onSelect: (index: number) => void; + onEdit: (index: number) => void; + onDelete: (serviceAccountName: string) => void; + onDuplicate: (index: number) => void; +} + +export function ServiceAccountsList({ + serviceAccounts, + selectedIndex, + onSelect, + onEdit, + onDelete, + onDuplicate +}: ServiceAccountsListProps) { + const [deleteConfirm, setDeleteConfirm] = useState(null); + + const handleDeleteClick = (serviceAccountName: string, e: React.MouseEvent) => { + e.stopPropagation(); + setDeleteConfirm(serviceAccountName); + }; + + const handleConfirmDelete = (serviceAccountName: string, e: React.MouseEvent) => { + e.stopPropagation(); + onDelete(serviceAccountName); + setDeleteConfirm(null); + }; + + const handleCancelDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + setDeleteConfirm(null); + }; + + const handleDuplicateClick = (index: number, e: React.MouseEvent) => { + e.stopPropagation(); + onDuplicate(index); + }; + + const handleEditClick = (index: number, e: React.MouseEvent) => { + e.stopPropagation(); + onEdit(index); + }; + + if (serviceAccounts.length === 0) { + return ( +
+
+ +
+

No Service Accounts

+

+ Create Service Accounts to manage authentication and authorization for your applications +

+
+ ); + } + + return ( +
+ {/* Service Accounts List */} +
+
+ {serviceAccounts.map((serviceAccount, index) => ( +
onSelect(index)} + > +
+ {/* Header with icon and name */} +
+
+
+
+ +
+
+
+
+

+ {serviceAccount.name} +

+ + {serviceAccount.namespace} + +
+
+
+ + {new Date(serviceAccount.createdAt).toLocaleDateString()} +
+ + {serviceAccount.automountServiceAccountToken !== false ? 'Auto-mount' : 'No auto-mount'} + + {/* Secrets summary inline */} + {(serviceAccount.secrets && serviceAccount.secrets.length > 0) || + (serviceAccount.imagePullSecrets && serviceAccount.imagePullSecrets.length > 0) ? ( +
+ {serviceAccount.secrets && serviceAccount.secrets.length > 0 && ( +
+ + {serviceAccount.secrets.length} +
+ )} + {serviceAccount.imagePullSecrets && serviceAccount.imagePullSecrets.length > 0 && ( +
+ + {serviceAccount.imagePullSecrets.length} +
+ )} +
+ ) : null} +
+
+
+ + {/* Action Buttons */} +
+ {deleteConfirm === serviceAccount.name ? ( + // Delete confirmation buttons +
+ + +
+ ) : ( + // Normal action buttons + <> + + + + + )} +
+
+ + {/* Delete confirmation warning */} + {deleteConfirm === serviceAccount.name && ( +
+
+ + Are you sure? +
+
+ This will delete the Service Account and may affect applications that use it for authentication. +
+
+ )} +
+
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/VisualPreview.tsx b/src/components/VisualPreview.tsx index c953719..6716d05 100644 --- a/src/components/VisualPreview.tsx +++ b/src/components/VisualPreview.tsx @@ -17,10 +17,12 @@ import { ExternalLink, GitBranch, GitCommit, - X + X, + Play } from 'lucide-react'; -import type { DeploymentConfig, DaemonSetConfig, Namespace, ConfigMap, Secret } from '../types'; -import { generateKubernetesYaml, generateDaemonSetYaml, generateConfigMapYaml, generateSecretYaml, generateNamespaceYaml } from '../utils/yamlGenerator'; +import type { DeploymentConfig, DaemonSetConfig, Namespace, ConfigMap, Secret, ServiceAccount } from '../types'; +import type { Job } from './JobManager'; +import { generateKubernetesYaml, generateDaemonSetYaml, generateConfigMapYaml, generateSecretYaml, generateNamespaceYaml, generateServiceAccountYaml, generateJobYaml, generateCronJobYaml } from '../utils/yamlGenerator'; import { YamlPreview } from './YamlPreview'; interface VisualPreviewProps { @@ -29,13 +31,16 @@ interface VisualPreviewProps { namespaces: Namespace[]; configMaps: ConfigMap[]; secrets: Secret[]; + serviceAccounts: ServiceAccount[]; + jobs: Job[]; containerRef?: React.RefObject; + filterType?: 'all' | 'deployments' | 'daemonsets' | 'namespaces' | 'configmaps' | 'secrets' | 'serviceaccounts' | 'jobs' | 'cronjobs'; } interface FlowNode { id: string; name: string; - type: 'deployment' | 'daemonset' | 'service' | 'pod' | 'configmap' | 'secret' | 'ingress' | 'namespace' | 'external'; + type: 'deployment' | 'daemonset' | 'service' | 'pod' | 'configmap' | 'secret' | 'ingress' | 'namespace' | 'external' | 'serviceaccount' | 'job' | 'cronjob'; namespace: string; status: 'healthy' | 'warning' | 'error' | 'pending' | 'syncing'; syncStatus: 'synced' | 'outofsync' | 'unknown'; @@ -50,6 +55,11 @@ interface FlowNode { dataKeys?: number; lastSync?: string; syncRevision?: string; + secrets?: number; + imagePullSecrets?: number; + schedule?: string; + completions?: number; + parallelism?: number; }; colorClass?: string; } @@ -60,7 +70,10 @@ export function VisualPreview({ namespaces, configMaps, secrets, - containerRef + serviceAccounts, + jobs, + containerRef, + filterType = 'all' }: VisualPreviewProps) { const showDetails = true; const [yamlModal, setYamlModal] = useState<{ open: boolean, title: string, yaml: string } | null>(null); @@ -86,18 +99,31 @@ export function VisualPreview({ const rowHeight = 260; // Increased for more space between deployments let currentY = 0; + // Filter resources based on filterType + const filteredDeployments = filterType === 'all' || filterType === 'deployments' ? deployments : []; + const filteredDaemonSets = filterType === 'all' || filterType === 'daemonsets' ? daemonSets : []; + const filteredNamespaces = filterType === 'all' || filterType === 'namespaces' ? namespaces : []; + const filteredConfigMaps = filterType === 'all' || filterType === 'configmaps' ? configMaps : []; + const filteredSecrets = filterType === 'all' || filterType === 'secrets' ? secrets : []; + const filteredServiceAccounts = filterType === 'all' || filterType === 'serviceaccounts' ? serviceAccounts : []; + const filteredJobs = filterType === 'all' || filterType === 'jobs' || filterType === 'cronjobs' ? jobs : []; + // Group by namespace - const namespaceGroups = namespaces.map(ns => ({ + const namespaceGroups = filteredNamespaces.map(ns => ({ namespace: ns, - deployments: deployments.filter(d => d.namespace === ns.name && d.appName), - daemonSets: daemonSets.filter(d => d.namespace === ns.name && d.appName), - configMaps: configMaps.filter(cm => cm.namespace === ns.name), - secrets: secrets.filter(s => s.namespace === ns.name) + deployments: filteredDeployments.filter(d => d.namespace === ns.name && d.appName), + daemonSets: filteredDaemonSets.filter(d => d.namespace === ns.name && d.appName), + configMaps: filteredConfigMaps.filter(cm => cm.namespace === ns.name), + secrets: filteredSecrets.filter(s => s.namespace === ns.name), + serviceAccounts: filteredServiceAccounts.filter(sa => sa.namespace === ns.name) })).filter(group => group.deployments.length > 0 || group.daemonSets.length > 0 || group.configMaps.length > 0 || - group.secrets.length > 0 + group.secrets.length > 0 || + group.serviceAccounts.length > 0 || + // Include namespace groups even if they only have standalone resources + (filterType === 'namespaces' && group.namespace) ); namespaceGroups.forEach((group) => { @@ -331,10 +357,581 @@ export function VisualPreview({ currentY += rowHeight; }); + // Process service accounts in a compact layout + if (group.serviceAccounts.length > 0) { + const baseY = currentY; + const colorClass = colorPalette[0]; // Use consistent color for service accounts + + // Create a compact grid layout for service accounts + const serviceAccountsPerRow = 3; + const serviceAccountSpacing = 220; // Reduced spacing between service accounts + + group.serviceAccounts.forEach((serviceAccount, saIndex) => { + const row = Math.floor(saIndex / serviceAccountsPerRow); + const col = saIndex % serviceAccountsPerRow; + const serviceAccountId = `serviceaccount-${serviceAccount.name}`; + + // Calculate service account status + const hasName = serviceAccount.name && serviceAccount.name.trim() !== ''; + const hasNamespace = serviceAccount.namespace && serviceAccount.namespace.trim() !== ''; + let serviceAccountStatus: 'healthy' | 'warning' | 'error' | 'pending' | 'syncing'; + let syncStatus: 'synced' | 'outofsync' | 'unknown'; + + if (hasName && hasNamespace) { + serviceAccountStatus = 'healthy'; + syncStatus = 'synced'; + } else if (hasName || hasNamespace) { + serviceAccountStatus = 'warning'; + syncStatus = 'outofsync'; + } else { + serviceAccountStatus = 'error'; + syncStatus = 'outofsync'; + } + + // Add service account node in compact grid + nodes.push({ + id: serviceAccountId, + name: serviceAccount.name, + type: 'serviceaccount', + namespace: serviceAccount.namespace, + status: serviceAccountStatus, + syncStatus: syncStatus, + position: { + x: col * serviceAccountSpacing, + y: baseY + (row * 120) // Reduced vertical spacing + }, + dependencies: [], + children: [], + metadata: { + secrets: serviceAccount.secrets?.length || 0, + imagePullSecrets: serviceAccount.imagePullSecrets?.length || 0, + lastSync: new Date().toISOString(), + syncRevision: `v${Date.now()}` + }, + colorClass: colorClass + }); + + // Add associated secrets if any (positioned to the left) + serviceAccount.secrets?.forEach((secretRef, secIndex) => { + const secret = secrets.find(s => s.name === secretRef.name); + if (secret) { + nodes.push({ + id: `secret-${secretRef.name}-sa-${saIndex}`, + name: secretRef.name, + type: 'secret', + namespace: secret.namespace, + status: 'healthy', + syncStatus: 'synced', + position: { + x: col * serviceAccountSpacing - 200, + y: baseY + (row * 120) + (secIndex * 40) // Compact secret positioning + }, + dependencies: [serviceAccountId], + children: [], + metadata: { + dataKeys: Object.keys(secret.data).length + }, + colorClass: colorClass + }); + } + }); + + // Add associated image pull secrets if any (positioned to the right) + serviceAccount.imagePullSecrets?.forEach((secretRef, ipsIndex) => { + const secret = secrets.find(s => s.name === secretRef.name); + if (secret) { + nodes.push({ + id: `imagepullsecret-${secretRef.name}-sa-${saIndex}`, + name: `${secretRef.name} (Image Pull)`, + type: 'secret', + namespace: secret.namespace, + status: 'healthy', + syncStatus: 'synced', + position: { + x: col * serviceAccountSpacing + 200, + y: baseY + (row * 120) + (ipsIndex * 40) // Compact secret positioning + }, + dependencies: [serviceAccountId], + children: [], + metadata: { + dataKeys: Object.keys(secret.data).length + }, + colorClass: colorClass + }); + } + }); + }); + + // Update currentY based on the number of rows needed + const totalRows = Math.ceil(group.serviceAccounts.length / serviceAccountsPerRow); + currentY += totalRows * 120 + 40; // Reduced spacing + } + + // Process namespaces + if (group.namespace) { + const namespaceId = `namespace-${group.namespace.name}`; + nodes.push({ + id: namespaceId, + name: group.namespace.name, + type: 'namespace', + namespace: group.namespace.name, + status: 'healthy', + syncStatus: 'synced', + position: { x: 0, y: currentY }, + dependencies: [], + children: [], + metadata: { + lastSync: new Date().toISOString(), + syncRevision: `v${Date.now()}` + }, + colorClass: colorPalette[1] + }); + currentY += 100; // Compact namespace spacing + } + + // Process jobs and cronjobs + const namespaceJobs = filteredJobs.filter(job => job.namespace === group.namespace.name); + + // Apply specific job/cronjob filtering + const filteredNamespaceJobs = filterType === 'jobs' + ? namespaceJobs.filter(job => job.type === 'job') + : filterType === 'cronjobs' + ? namespaceJobs.filter(job => job.type === 'cronjob') + : namespaceJobs; + + if (filteredNamespaceJobs.length > 0) { + const jobsPerRow = 2; + const jobSpacing = 250; + + filteredNamespaceJobs.forEach((job, jobIndex) => { + const row = Math.floor(jobIndex / jobsPerRow); + const col = jobIndex % jobsPerRow; + const jobId = `${job.type}-${job.name}`; + + // Calculate job status + const hasName = job.name && job.name.trim() !== ''; + const hasNamespace = job.namespace && job.namespace.trim() !== ''; + const hasContainers = job.containers && job.containers.length > 0; + let jobStatus: 'healthy' | 'warning' | 'error' | 'pending' | 'syncing'; + let syncStatus: 'synced' | 'outofsync' | 'unknown'; + + if (hasName && hasNamespace && hasContainers) { + jobStatus = 'healthy'; + syncStatus = 'synced'; + } else if (hasName && hasNamespace) { + jobStatus = 'warning'; + syncStatus = 'outofsync'; + } else { + jobStatus = 'error'; + syncStatus = 'outofsync'; + } + + // Add job/cronjob node + nodes.push({ + id: jobId, + name: job.name, + type: job.type, + namespace: job.namespace, + status: jobStatus, + syncStatus: syncStatus, + position: { + x: col * jobSpacing, + y: currentY + (row * 100) // Compact job spacing + }, + dependencies: [], + children: [], + metadata: { + containers: job.containers?.length || 0, + schedule: job.schedule, + completions: job.completions, + parallelism: job.replicas, + lastSync: new Date().toISOString(), + syncRevision: `v${Date.now()}` + }, + colorClass: job.type === 'cronjob' ? colorPalette[2] : colorPalette[3] + }); + }); + + // Update currentY based on the number of rows needed + const totalJobRows = Math.ceil(filteredNamespaceJobs.length / jobsPerRow); + currentY += totalJobRows * 100 + 40; // Compact job spacing + } + currentY += rowHeight * 0.2; }); + + // Handle standalone resources that don't need to be grouped by namespace + if (filterType === 'deployments' || filterType === 'daemonsets' || filterType === 'configmaps' || filterType === 'secrets' || filterType === 'serviceaccounts' || filterType === 'jobs' || filterType === 'cronjobs') { + // Add standalone deployments + if (filterType === 'deployments') { + filteredDeployments.forEach((deployment, depIndex) => { + const baseY = currentY; + const colorIdx = depIndex % colorPalette.length; + const colorClass = colorPalette[colorIdx]; + const deploymentId = `deployment-${deployment.appName}`; + const serviceId = `service-${deployment.appName}`; + + // Calculate deployment status + const hasContainers = deployment.containers && deployment.containers.length > 0; + const hasValidContainers = hasContainers && deployment.containers.every(c => c.name && c.image); + const hasProperPorts = deployment.port > 0 && deployment.targetPort > 0; + let deploymentStatus: 'healthy' | 'warning' | 'error' | 'pending' | 'syncing'; + let syncStatus: 'synced' | 'outofsync' | 'unknown'; + + if (hasValidContainers && hasProperPorts) { + deploymentStatus = 'healthy'; + syncStatus = 'synced'; + } else if (hasContainers && hasProperPorts) { + deploymentStatus = 'warning'; + syncStatus = 'outofsync'; + } else { + deploymentStatus = 'error'; + syncStatus = 'outofsync'; + } + + // Add deployment node + nodes.push({ + id: deploymentId, + name: deployment.appName, + type: 'deployment', + namespace: deployment.namespace, + status: deploymentStatus, + syncStatus: syncStatus, + position: { x: 0, y: baseY }, + dependencies: [], + children: [serviceId], + metadata: { + replicas: deployment.replicas, + readyReplicas: hasValidContainers ? deployment.replicas : 0, + containers: deployment.containers?.length || 0, + lastSync: new Date().toISOString(), + syncRevision: `v${Date.now()}` + }, + colorClass: colorClass + }); + + // Add service node + nodes.push({ + id: serviceId, + name: `${deployment.appName}-service`, + type: 'service', + namespace: deployment.namespace, + status: hasValidContainers ? 'healthy' : 'warning', + syncStatus: hasValidContainers ? 'synced' : 'outofsync', + position: { x: 200, y: baseY }, + dependencies: [deploymentId], + children: deployment.ingress?.enabled ? [`ingress-${deployment.appName}`] : [], + metadata: { + ports: [deployment.port] + }, + colorClass: colorClass + }); + + // Add pod node + const podId = `pod-${deployment.appName}`; + nodes.push({ + id: podId, + name: `${deployment.appName}-pod`, + type: 'pod', + namespace: deployment.namespace, + status: hasValidContainers ? 'healthy' : 'error', + syncStatus: hasValidContainers ? 'synced' : 'outofsync', + position: { x: -200, y: baseY + 60 }, + dependencies: [deploymentId], + children: [], + metadata: { + containers: deployment.containers?.length || 0, + replicas: deployment.replicas + }, + colorClass: colorClass + }); + + // Add ingress if enabled + if (deployment.ingress?.enabled) { + nodes.push({ + id: `ingress-${deployment.appName}`, + name: `${deployment.appName}-ingress`, + type: 'ingress', + namespace: deployment.namespace, + status: 'healthy', + syncStatus: 'synced', + position: { x: 400, y: baseY }, + dependencies: [serviceId], + children: [`external-${deployment.appName}`], + metadata: {}, + colorClass: colorClass + }); + nodes.push({ + id: `external-${deployment.appName}`, + name: 'External Traffic', + type: 'external', + namespace: deployment.namespace, + status: 'healthy', + syncStatus: 'synced', + position: { x: 600, y: baseY }, + dependencies: [`ingress-${deployment.appName}`], + children: [], + metadata: {}, + colorClass: colorClass + }); + } + + currentY += 260; // Move to next deployment + }); + } + + // Add standalone daemonSets + if (filterType === 'daemonsets') { + filteredDaemonSets.forEach((daemonSet, dsIndex) => { + const baseY = currentY; + const colorIdx = dsIndex % colorPalette.length; + const colorClass = colorPalette[colorIdx]; + const daemonSetId = `daemonset-${daemonSet.appName}`; + const serviceId = `service-${daemonSet.appName}`; + + // Calculate daemonSet status + const hasContainers = daemonSet.containers && daemonSet.containers.length > 0; + const hasValidContainers = hasContainers && daemonSet.containers.every(c => c.name && c.image); + let daemonSetStatus: 'healthy' | 'warning' | 'error' | 'pending' | 'syncing'; + let syncStatus: 'synced' | 'outofsync' | 'unknown'; + + if (hasValidContainers) { + daemonSetStatus = 'healthy'; + syncStatus = 'synced'; + } else if (hasContainers) { + daemonSetStatus = 'warning'; + syncStatus = 'outofsync'; + } else { + daemonSetStatus = 'error'; + syncStatus = 'outofsync'; + } + + // Add daemonSet node + nodes.push({ + id: daemonSetId, + name: daemonSet.appName, + type: 'daemonset', + namespace: daemonSet.namespace, + status: daemonSetStatus, + syncStatus: syncStatus, + position: { x: 0, y: baseY }, + dependencies: [], + children: daemonSet.serviceEnabled ? [serviceId] : [], + metadata: { + containers: daemonSet.containers?.length || 0, + lastSync: new Date().toISOString(), + syncRevision: `v${Date.now()}` + }, + colorClass: colorClass + }); + + // Add service node if enabled + if (daemonSet.serviceEnabled) { + nodes.push({ + id: serviceId, + name: `${daemonSet.appName}-service`, + type: 'service', + namespace: daemonSet.namespace, + status: hasValidContainers ? 'healthy' : 'warning', + syncStatus: hasValidContainers ? 'synced' : 'outofsync', + position: { x: 200, y: baseY }, + dependencies: [daemonSetId], + children: [], + metadata: { + ports: [daemonSet.port] + }, + colorClass: colorClass + }); + } + + // Add pod node + const podId = `pod-${daemonSet.appName}`; + nodes.push({ + id: podId, + name: `${daemonSet.appName}-pod`, + type: 'pod', + namespace: daemonSet.namespace, + status: hasValidContainers ? 'healthy' : 'error', + syncStatus: hasValidContainers ? 'synced' : 'outofsync', + position: { x: -200, y: baseY + 60 }, + dependencies: [daemonSetId], + children: [], + metadata: { + containers: daemonSet.containers?.length || 0 + }, + colorClass: colorClass + }); + + currentY += 260; // Move to next daemonSet + }); + } + + // Add standalone configmaps in grid layout + if (filterType === 'configmaps') { + const configMapsPerRow = 3; + const configMapSpacing = 250; + + filteredConfigMaps.forEach((configMap, index) => { + const row = Math.floor(index / configMapsPerRow); + const col = index % configMapsPerRow; + + nodes.push({ + id: `configmap-${configMap.name}`, + name: configMap.name, + type: 'configmap', + namespace: configMap.namespace, + status: 'healthy', + syncStatus: 'synced', + position: { + x: col * configMapSpacing, + y: currentY + (row * 120) // Compact spacing + }, + dependencies: [], + children: [], + metadata: { + dataKeys: Object.keys(configMap.data).length + }, + colorClass: colorPalette[0] + }); + }); + + // Update currentY based on the number of rows needed + const totalRows = Math.ceil(filteredConfigMaps.length / configMapsPerRow); + currentY += totalRows * 120 + 40; // Compact spacing + } + + // Add standalone secrets in grid layout + if (filterType === 'secrets') { + const secretsPerRow = 3; + const secretSpacing = 250; + + filteredSecrets.forEach((secret, index) => { + const row = Math.floor(index / secretsPerRow); + const col = index % secretsPerRow; + + nodes.push({ + id: `secret-${secret.name}`, + name: secret.name, + type: 'secret', + namespace: secret.namespace, + status: 'healthy', + syncStatus: 'synced', + position: { + x: col * secretSpacing, + y: currentY + (row * 120) // Compact spacing + }, + dependencies: [], + children: [], + metadata: { + dataKeys: Object.keys(secret.data).length + }, + colorClass: colorPalette[1] + }); + }); + + // Update currentY based on the number of rows needed + const totalRows = Math.ceil(filteredSecrets.length / secretsPerRow); + currentY += totalRows * 120 + 40; // Compact spacing + } + + // Add standalone service accounts in grid layout + if (filterType === 'serviceaccounts') { + const serviceAccountsPerRow = 3; + const serviceAccountSpacing = 250; + + filteredServiceAccounts.forEach((serviceAccount, index) => { + const row = Math.floor(index / serviceAccountsPerRow); + const col = index % serviceAccountsPerRow; + + nodes.push({ + id: `serviceaccount-${serviceAccount.name}`, + name: serviceAccount.name, + type: 'serviceaccount', + namespace: serviceAccount.namespace, + status: 'healthy', + syncStatus: 'synced', + position: { + x: col * serviceAccountSpacing, + y: currentY + (row * 120) // Compact spacing + }, + dependencies: [], + children: [], + metadata: { + secrets: serviceAccount.secrets?.length || 0, + imagePullSecrets: serviceAccount.imagePullSecrets?.length || 0 + }, + colorClass: colorPalette[2] + }); + }); + + // Update currentY based on the number of rows needed + const totalRows = Math.ceil(filteredServiceAccounts.length / serviceAccountsPerRow); + currentY += totalRows * 120 + 40; // Compact spacing + } + + // Add standalone jobs/cronjobs in grid layout + if (filterType === 'jobs' || filterType === 'cronjobs') { + const jobsToShow = filterType === 'jobs' + ? filteredJobs.filter(job => job.type === 'job') + : filteredJobs.filter(job => job.type === 'cronjob'); + + const jobsPerRow = 3; + const jobSpacing = 250; + + jobsToShow.forEach((job, index) => { + const row = Math.floor(index / jobsPerRow); + const col = index % jobsPerRow; + + const hasName = job.name && job.name.trim() !== ''; + const hasNamespace = job.namespace && job.namespace.trim() !== ''; + const hasContainers = job.containers && job.containers.length > 0; + let jobStatus: 'healthy' | 'warning' | 'error' | 'pending' | 'syncing'; + let syncStatus: 'synced' | 'outofsync' | 'unknown'; + + if (hasName && hasNamespace && hasContainers) { + jobStatus = 'healthy'; + syncStatus = 'synced'; + } else if (hasName && hasNamespace) { + jobStatus = 'warning'; + syncStatus = 'outofsync'; + } else { + jobStatus = 'error'; + syncStatus = 'outofsync'; + } + + nodes.push({ + id: `${job.type}-${job.name}`, + name: job.name, + type: job.type, + namespace: job.namespace, + status: jobStatus, + syncStatus: syncStatus, + position: { + x: col * jobSpacing, + y: currentY + (row * 120) // Compact spacing + }, + dependencies: [], + children: [], + metadata: { + containers: job.containers?.length || 0, + schedule: job.schedule, + completions: job.completions, + parallelism: job.replicas, + lastSync: new Date().toISOString(), + syncRevision: `v${Date.now()}` + }, + colorClass: job.type === 'cronjob' ? colorPalette[2] : colorPalette[3] + }); + }); + + // Update currentY based on the number of rows needed + const totalRows = Math.ceil(jobsToShow.length / jobsPerRow); + currentY += totalRows * 120 + 40; // Compact spacing + } + } + return nodes; - }, [deployments, daemonSets, namespaces, configMaps, secrets]); + }, [deployments, daemonSets, namespaces, configMaps, secrets, serviceAccounts, jobs, filterType]); // Bounding box calculation const padding = 40; @@ -397,6 +994,13 @@ export function VisualPreview({ case 'pod': return ; case 'configmap': + return ; + case 'serviceaccount': + return ; + case 'job': + return ; + case 'cronjob': + return ; return ; case 'secret': return ; @@ -498,6 +1102,69 @@ export function VisualPreview({ const ns = namespaces.find(ns => ns.name === node.name); if (ns) return generateNamespaceYaml([ns]); } + if (node.type === 'serviceaccount') { + const sa = serviceAccounts.find(sa => sa.name === node.name); + if (sa) return generateServiceAccountYaml([sa]); + } + if (node.type === 'job') { + const job = jobs.find(job => job.name === node.name && job.type === 'job'); + if (job) { + // Convert Job to JobConfig format + const labelsObj = job.labels.reduce((acc, label) => { + acc[label.key] = label.value; + return acc; + }, {} as Record); + + const jobConfig = { + name: job.name, + namespace: job.namespace, + labels: labelsObj, + annotations: {}, + containers: job.containers, + completions: job.completions, + parallelism: job.replicas, + backoffLimit: job.backoffLimit, + activeDeadlineSeconds: job.activeDeadlineSeconds, + restartPolicy: job.restartPolicy + }; + return generateJobYaml([jobConfig]); + } + } + if (node.type === 'cronjob') { + const job = jobs.find(job => job.name === node.name && job.type === 'cronjob'); + if (job) { + // Convert Job to CronJobConfig format + const labelsObj = job.labels.reduce((acc, label) => { + acc[label.key] = label.value; + return acc; + }, {} as Record); + + const cronJobConfig = { + name: job.name, + namespace: job.namespace, + labels: labelsObj, + annotations: {}, + schedule: job.schedule || '', + concurrencyPolicy: job.concurrencyPolicy, + startingDeadlineSeconds: job.startingDeadline ? parseInt(job.startingDeadline) : undefined, + successfulJobsHistoryLimit: job.historySuccess ? parseInt(job.historySuccess) : undefined, + failedJobsHistoryLimit: job.historyFailure ? parseInt(job.historyFailure) : undefined, + jobTemplate: { + name: job.name, + namespace: job.namespace, + labels: labelsObj, + annotations: {}, + completions: job.completions, + parallelism: job.replicas, + backoffLimit: job.backoffLimit, + activeDeadlineSeconds: job.activeDeadlineSeconds, + restartPolicy: job.restartPolicy, + containers: job.containers + } + }; + return generateCronJobYaml([cronJobConfig]); + } + } if (node.type === 'ingress') { const depName = node.name.replace(/-ingress$/, ''); const deployment = deployments.find(d => d.appName === depName); @@ -516,7 +1183,34 @@ export function VisualPreview({ setYamlModal({ open: true, title: node.name, yaml }); }; - if (!validDeployments.length && !validDaemonSets.length) { + const validServiceAccounts = serviceAccounts.filter(sa => sa.name); + const validJobs = jobs.filter(job => job.name); + + // Check if there are any resources for the current filter + const hasFilteredResources = () => { + if (filterType === 'all') { + return validDeployments.length > 0 || validDaemonSets.length > 0 || validServiceAccounts.length > 0 || validJobs.length > 0; + } else if (filterType === 'deployments') { + return validDeployments.length > 0; + } else if (filterType === 'daemonsets') { + return validDaemonSets.length > 0; + } else if (filterType === 'serviceaccounts') { + return validServiceAccounts.length > 0; + } else if (filterType === 'jobs') { + return validJobs.filter(job => job.type === 'job').length > 0; + } else if (filterType === 'cronjobs') { + return validJobs.filter(job => job.type === 'cronjob').length > 0; + } else if (filterType === 'configmaps') { + return configMaps.length > 0; + } else if (filterType === 'secrets') { + return secrets.length > 0; + } else if (filterType === 'namespaces') { + return namespaces.length > 0; + } + return false; + }; + + if (!hasFilteredResources()) { return (
@@ -524,10 +1218,10 @@ export function VisualPreview({

No Resources to Visualize

-

Create your first deployment to see the Visual diagram

+

Create your first deployment or service account to see the Visual diagram

- Add deployments, ConfigMaps, and Secrets to visualize your Kubernetes resource flow and dependencies + Add deployments, service accounts, ConfigMaps, and Secrets to visualize your Kubernetes resource flow and dependencies

@@ -654,6 +1348,18 @@ export function VisualPreview({ {node.metadata.dataKeys} keys
)} + {node.metadata.secrets !== undefined && ( +
+ + {node.metadata.secrets} secrets +
+ )} + {node.metadata.imagePullSecrets !== undefined && ( +
+ + {node.metadata.imagePullSecrets} image pull secrets +
+ )} {node.metadata.lastSync && (
diff --git a/src/types/index.ts b/src/types/index.ts index 3109901..7fe58ec 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -83,6 +83,7 @@ export interface DeploymentConfig { secrets: Array<{ name: string; data: Record }>; // Legacy - for backward compatibility selectedConfigMaps: string[]; // References to ConfigMap names selectedSecrets: string[]; // References to Secret names + serviceAccount?: string; // Reference to ServiceAccount name ingress: IngressConfig; // Legacy fields for backward compatibility image?: string; @@ -108,6 +109,7 @@ export interface DaemonSetConfig { secrets: Array<{ name: string; data: Record }>; // Legacy - for backward compatibility selectedConfigMaps: string[]; // References to ConfigMap names selectedSecrets: string[]; // References to Secret names + serviceAccount?: string; // Reference to ServiceAccount name nodeSelector?: Record; // Optional node selector // Legacy fields for backward compatibility image?: string; @@ -125,6 +127,21 @@ export interface Namespace { createdAt: string; } +export interface ServiceAccount { + name: string; + namespace: string; + labels: Record; + annotations: Record; + secrets?: Array<{ + name: string; + }>; + imagePullSecrets?: Array<{ + name: string; + }>; + automountServiceAccountToken?: boolean; + createdAt: string; +} + export interface KubernetesResource { apiVersion: string; kind: string; diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts index a6f7a19..b716642 100644 --- a/src/utils/localStorage.ts +++ b/src/utils/localStorage.ts @@ -1,4 +1,4 @@ -import type { DeploymentConfig, DaemonSetConfig, Namespace, ConfigMap, Secret, ProjectSettings } from '../types'; +import type { DeploymentConfig, DaemonSetConfig, Namespace, ConfigMap, Secret, ServiceAccount, ProjectSettings } from '../types'; // Job interface from JobManager component export interface Job { @@ -33,6 +33,7 @@ export interface KubeConfig { jobs: Job[]; configMaps: ConfigMap[]; secrets: Secret[]; + serviceAccounts: ServiceAccount[]; namespaces: Namespace[]; projectSettings: ProjectSettings; generatedYaml?: string; @@ -117,6 +118,7 @@ export function saveConfig(config: Partial): boolean { jobs: config.jobs || [], configMaps: config.configMaps || [], secrets: config.secrets || [], + serviceAccounts: config.serviceAccounts || [], namespaces: config.namespaces || [], projectSettings: config.projectSettings || { name: 'my-project', diff --git a/src/utils/yamlGenerator.ts b/src/utils/yamlGenerator.ts index 2257ecf..3b51ba4 100644 --- a/src/utils/yamlGenerator.ts +++ b/src/utils/yamlGenerator.ts @@ -1,4 +1,4 @@ -import type { DeploymentConfig, DaemonSetConfig, KubernetesResource, Namespace, ConfigMap, Secret, ProjectSettings, JobConfig, CronJobConfig, Container, EnvVar } from '../types'; +import type { DeploymentConfig, DaemonSetConfig, KubernetesResource, Namespace, ConfigMap, Secret, ServiceAccount, ProjectSettings, JobConfig, CronJobConfig, Container, EnvVar } from '../types'; export function generateKubernetesYaml(config: DeploymentConfig, projectSettings?: ProjectSettings): string { if (!config.appName) { @@ -46,6 +46,7 @@ export function generateKubernetesYaml(config: DeploymentConfig, projectSettings } }, spec: { + ...(config.serviceAccount && { serviceAccountName: config.serviceAccount }), containers: generateContainers(config), ...(config.volumes.length > 0 && { volumes: config.volumes.map(v => ({ @@ -459,6 +460,144 @@ export function generateSecretYaml(secrets: Secret[], projectSettings?: ProjectS return allResources.join('\n'); } +export function generateServiceAccountYaml(serviceAccounts: ServiceAccount[], projectSettings?: ProjectSettings): string { + if (serviceAccounts.length === 0) { + return '# No Service Accounts configured'; + } + + const allResources: string[] = []; + + serviceAccounts.forEach(serviceAccount => { + // Merge global labels with service account labels + const mergedLabels = projectSettings ? { + ...projectSettings.globalLabels, + ...serviceAccount.labels, + project: projectSettings.name + } : serviceAccount.labels; + + const serviceAccountResource: KubernetesResource = { + apiVersion: 'v1', + kind: 'ServiceAccount', + metadata: { + name: serviceAccount.name, + namespace: serviceAccount.namespace, + ...(Object.keys(mergedLabels).length > 0 && { labels: mergedLabels }), + ...(Object.keys(serviceAccount.annotations).length > 0 && { annotations: serviceAccount.annotations }), + ...(serviceAccount.secrets && serviceAccount.secrets.length > 0 && { + secrets: serviceAccount.secrets + }), + ...(serviceAccount.imagePullSecrets && serviceAccount.imagePullSecrets.length > 0 && { + imagePullSecrets: serviceAccount.imagePullSecrets + }) + }, + ...(serviceAccount.automountServiceAccountToken !== undefined && { + automountServiceAccountToken: serviceAccount.automountServiceAccountToken + }) + }; + + allResources.push(objectToYaml(serviceAccountResource)); + }); + + return allResources.join('\n'); +} + +export function generateJobYaml(jobs: JobConfig[], projectSettings?: ProjectSettings): string { + if (jobs.length === 0) { + return '# No jobs to generate'; + } + + const resources: KubernetesResource[] = []; + + jobs.forEach(job => { + // Merge global labels with job labels + const mergedLabels = projectSettings ? { + ...projectSettings.globalLabels, + ...job.labels, + project: projectSettings.name + } : job.labels; + + const resource: KubernetesResource = { + apiVersion: 'batch/v1', + kind: 'Job', + metadata: { + name: job.name, + namespace: job.namespace, + ...(Object.keys(mergedLabels).length > 0 && { labels: mergedLabels }), + ...(Object.keys(job.annotations).length > 0 && { annotations: job.annotations }) + }, + spec: { + ...(job.completions && { completions: job.completions }), + ...(job.parallelism && { parallelism: job.parallelism }), + ...(job.backoffLimit && { backoffLimit: job.backoffLimit }), + ...(job.activeDeadlineSeconds && { activeDeadlineSeconds: job.activeDeadlineSeconds }), + template: { + spec: { + restartPolicy: job.restartPolicy, + containers: job.containers + } + } + } + }; + + resources.push(resource); + }); + + return resources.map(resource => objectToYaml(resource)).join('\n---\n'); +} + +export function generateCronJobYaml(cronjobs: CronJobConfig[], projectSettings?: ProjectSettings): string { + if (cronjobs.length === 0) { + return '# No cronjobs to generate'; + } + + const resources: KubernetesResource[] = []; + + cronjobs.forEach(cronjob => { + // Merge global labels with cronjob labels + const mergedLabels = projectSettings ? { + ...projectSettings.globalLabels, + ...cronjob.labels, + project: projectSettings.name + } : cronjob.labels; + + const resource: KubernetesResource = { + apiVersion: 'batch/v1', + kind: 'CronJob', + metadata: { + name: cronjob.name, + namespace: cronjob.namespace, + ...(Object.keys(mergedLabels).length > 0 && { labels: mergedLabels }), + ...(Object.keys(cronjob.annotations).length > 0 && { annotations: cronjob.annotations }) + }, + spec: { + schedule: cronjob.schedule, + ...(cronjob.concurrencyPolicy && { concurrencyPolicy: cronjob.concurrencyPolicy }), + ...(cronjob.startingDeadlineSeconds && { startingDeadlineSeconds: cronjob.startingDeadlineSeconds }), + ...(cronjob.successfulJobsHistoryLimit && { successfulJobsHistoryLimit: cronjob.successfulJobsHistoryLimit }), + ...(cronjob.failedJobsHistoryLimit && { failedJobsHistoryLimit: cronjob.failedJobsHistoryLimit }), + jobTemplate: { + spec: { + ...(cronjob.jobTemplate.completions && { completions: cronjob.jobTemplate.completions }), + ...(cronjob.jobTemplate.parallelism && { parallelism: cronjob.jobTemplate.parallelism }), + ...(cronjob.jobTemplate.backoffLimit && { backoffLimit: cronjob.jobTemplate.backoffLimit }), + ...(cronjob.jobTemplate.activeDeadlineSeconds && { activeDeadlineSeconds: cronjob.jobTemplate.activeDeadlineSeconds }), + template: { + spec: { + restartPolicy: cronjob.jobTemplate.restartPolicy, + containers: cronjob.jobTemplate.containers + } + } + } + } + } + }; + + resources.push(resource); + }); + + return resources.map(resource => objectToYaml(resource)).join('\n---\n'); +} + export function generateMultiDeploymentYaml( deployments: DeploymentConfig[], namespaces: Namespace[] = [], @@ -467,7 +606,8 @@ export function generateMultiDeploymentYaml( projectSettings?: ProjectSettings, jobs: JobConfig[] = [], cronjobs: CronJobConfig[] = [], - daemonSets: DaemonSetConfig[] = [] + daemonSets: DaemonSetConfig[] = [], + serviceAccounts: ServiceAccount[] = [] ): string { // Check if we have any meaningful content if ( @@ -477,7 +617,8 @@ export function generateMultiDeploymentYaml( secrets.length === 0 && jobs.length === 0 && cronjobs.length === 0 && - daemonSets.length === 0 + daemonSets.length === 0 && + serviceAccounts.length === 0 ) { return `# Welcome to Kube Composer! # @@ -530,7 +671,7 @@ data: ); // Add header comment - if (deployments.length > 0 || daemonSets.length > 0 || customNamespaces.length > 0 || configMaps.length > 0 || secrets.length > 0 || jobs.length > 0 || cronjobs.length > 0) { + if (deployments.length > 0 || daemonSets.length > 0 || customNamespaces.length > 0 || configMaps.length > 0 || secrets.length > 0 || jobs.length > 0 || cronjobs.length > 0 || serviceAccounts.length > 0) { allResources.push(`# Kubernetes Configuration`); allResources.push(`# Generated by Kube Composer`); @@ -553,6 +694,9 @@ data: if (secrets.length > 0) { allResources.push(`# Secrets: ${secrets.length}`); } + if (serviceAccounts.length > 0) { + allResources.push(`# Service Accounts: ${serviceAccounts.length}`); + } if (deployments.length > 0) { allResources.push(`# Deployments: ${deployments.filter(d => d.appName).length}`); const totalContainers = deployments.reduce((sum, d) => sum + (d.containers?.length || 1), 0); @@ -679,6 +823,50 @@ data: allResources.push(objectToYaml(secretResource)); }); + if (serviceAccounts.length > 0 || deployments.length > 0 || daemonSets.length > 0 || jobs.length > 0 || cronjobs.length > 0) { + allResources.push('---'); + allResources.push(''); + } + } + + // Generate Service Account resources + if (serviceAccounts.length > 0) { + allResources.push('# === SERVICE ACCOUNTS ==='); + serviceAccounts.forEach((serviceAccount, index) => { + if (index > 0) { + allResources.push('---'); + } + + // Merge global labels with service account labels + const mergedLabels = projectSettings ? { + ...projectSettings.globalLabels, + ...serviceAccount.labels, + project: projectSettings.name + } : serviceAccount.labels; + + const serviceAccountResource: KubernetesResource = { + apiVersion: 'v1', + kind: 'ServiceAccount', + metadata: { + name: serviceAccount.name, + namespace: serviceAccount.namespace, + ...(Object.keys(mergedLabels).length > 0 && { labels: mergedLabels }), + ...(Object.keys(serviceAccount.annotations).length > 0 && { annotations: serviceAccount.annotations }), + ...(serviceAccount.secrets && serviceAccount.secrets.length > 0 && { + secrets: serviceAccount.secrets + }), + ...(serviceAccount.imagePullSecrets && serviceAccount.imagePullSecrets.length > 0 && { + imagePullSecrets: serviceAccount.imagePullSecrets + }) + }, + ...(serviceAccount.automountServiceAccountToken !== undefined && { + automountServiceAccountToken: serviceAccount.automountServiceAccountToken + }) + }; + + allResources.push(objectToYaml(serviceAccountResource)); + }); + if (deployments.length > 0 || daemonSets.length > 0 || jobs.length > 0 || cronjobs.length > 0) { allResources.push('---'); allResources.push(''); @@ -1011,6 +1199,7 @@ export function generateDaemonSetYaml(config: DaemonSetConfig, projectSettings?: } }, spec: { + ...(config.serviceAccount && { serviceAccountName: config.serviceAccount }), containers: generateContainers(config), ...(config.volumes.length > 0 && { volumes: config.volumes.map(v => ({