diff --git a/webapp/backend/app.py b/webapp/backend/app.py index e14ef2a..e1a87be 100644 --- a/webapp/backend/app.py +++ b/webapp/backend/app.py @@ -61,7 +61,7 @@ def create_app(): # Logging Configuration setup_logging() - # Middleware pour logger les requêtes + # Middleware to log requests @app.before_request def before_request(): """Save request start time for performance logging.""" diff --git a/webapp/backend/routes/cluster.py b/webapp/backend/routes/cluster.py index ac7a706..74cda4b 100644 --- a/webapp/backend/routes/cluster.py +++ b/webapp/backend/routes/cluster.py @@ -6,14 +6,28 @@ from services import ( generate_from_cluster, get_namespaces, get_resource_types, + get_current_context, ) from utils import InputValidator, ResponseBuilder cluster_bp = Blueprint('cluster', __name__) +@cluster_bp.route('/api/cluster/context', methods=['GET']) +def get_context(): + """Return the name of the currently active kubectl context.""" + try: + context = get_current_context() + return ResponseBuilder.success({"context": context}) + except RuntimeError as e: + return ResponseBuilder.error(str(e)) + except Exception as e: + return ResponseBuilder.error(f"Unexpected error: {str(e)}") + + @cluster_bp.route('/api/cluster/namespaces', methods=['GET']) def list_namespaces(): + """Return the list of namespaces available in the connected Kubernetes cluster.""" try: namespaces = get_namespaces() return ResponseBuilder.success({ @@ -28,9 +42,9 @@ def list_namespaces(): @cluster_bp.route('/api/cluster/resource-types', methods=['GET']) def list_resource_types(): - namespace = request.args.get('namespace') + """Return all resource types known by the cluster, tagged with namespace scope and common status.""" try: - resource_types = get_resource_types(namespace=namespace) + resource_types = get_resource_types() return ResponseBuilder.success({ "resourceTypes": resource_types, "count": len(resource_types) @@ -58,7 +72,7 @@ def generate_cluster_diagram(): route = request.path params = ( f"namespace={namespace};" - f"resourceTypes={','.join(resource_types) if resource_types else 'default'};" + f"resourceTypes={','.join(resource_types)};" f"allNamespaces={all_namespaces};" f"format={output_format};" f"extraArgs={compact_for_log(extra_args)};" @@ -66,6 +80,10 @@ def generate_cluster_diagram(): ) log_to_csv(client_ip, route, params) + # Validate resource types + if not resource_types: + return ResponseBuilder.validation_error("resourceTypes", "At least one resource type must be selected") + # Validate namespace if provided if namespace and not InputValidator.validate_k8s_name(namespace): return ResponseBuilder.validation_error("namespace", "Invalid namespace name") @@ -83,7 +101,7 @@ def generate_cluster_diagram(): # Generate diagram using kubectl-diagrams result = generate_from_cluster( namespace=namespace, - resource_types=resource_types if resource_types else None, + resource_types=resource_types, all_namespaces=all_namespaces, output_format=output_format, extra_args=extra_args, diff --git a/webapp/backend/services/__init__.py b/webapp/backend/services/__init__.py index 08c2406..86df585 100644 --- a/webapp/backend/services/__init__.py +++ b/webapp/backend/services/__init__.py @@ -8,6 +8,7 @@ from .clusterService import ( generate_from_cluster, get_namespaces, get_resource_types, + get_current_context, ) __all__ = [ @@ -19,4 +20,6 @@ __all__ = [ 'generate_from_cluster', 'get_namespaces', 'get_resource_types', + 'get_current_context', ] + diff --git a/webapp/backend/services/clusterService.py b/webapp/backend/services/clusterService.py index 0d8844a..c73662a 100644 --- a/webapp/backend/services/clusterService.py +++ b/webapp/backend/services/clusterService.py @@ -3,6 +3,7 @@ import subprocess import json import os import uuid +import tempfile from typing import List, Optional, Dict, Any from constants import MIME_TYPES @@ -10,264 +11,218 @@ from .models import DiagramResult from .file_manager import FileManager from .utils import parse_extra_args, has_fatal_error, encode_content +COMMON_RESOURCE_TYPES = frozenset({ + 'pods', 'services', 'deployments', 'replicasets', 'statefulsets', + 'daemonsets', 'configmaps', 'secrets', 'ingresses', + 'persistentvolumeclaims', 'persistentvolumes', 'nodes', + 'namespaces', 'serviceaccounts', 'roles', 'rolebindings', + 'clusterroles', 'clusterrolebindings', 'jobs', 'cronjobs', + 'horizontalpodautoscalers', 'networkpolicies', 'storageclasses', + 'runtimeclasses', + # events and endpoints excluded: they produce noisy intermediate resources +}) + +_KUBECTL_NOT_FOUND = ( + "kubectl is not installed or not found in PATH. " + "Install it from https://kubernetes.io/docs/tasks/tools/ and ensure it is available." +) +_CANNOT_REACH_API = ( + "Cannot reach the Kubernetes API server. " + "Start your cluster (e.g. minikube start, kind create cluster, k3d cluster create) " + "and verify your kubeconfig with: kubectl config current-context" +) + + +def _raise_connection_error(error_msg: str, fallback_msg: str) -> None: + """Raise RuntimeError with a user-friendly message based on kubectl's connection error output.""" + if "connect: no route to host" in error_msg or "dial tcp" in error_msg: + raise RuntimeError( + "Cannot reach the Kubernetes API server (no route to host). " + "Start your cluster (e.g. minikube start, kind create cluster) " + "and verify your kubeconfig with: kubectl config current-context" + ) + if "Unable to connect to the server" in error_msg: + raise RuntimeError(_CANNOT_REACH_API) + if "connection refused" in error_msg.lower(): + raise RuntimeError( + "Connection to the Kubernetes API server was refused. " + "Make sure your cluster is running and the API server is accessible." + ) + raise RuntimeError(fallback_msg) + + +def _run_kubectl(cmd: List[str], timeout: int) -> subprocess.CompletedProcess: + """ + Run a kubectl command and return the completed process. + Raises RuntimeError if kubectl is not installed or the command times out. + CalledProcessError propagates for the caller to handle context-specifically. + """ + try: + return subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout) + except FileNotFoundError: + raise RuntimeError(_KUBECTL_NOT_FOUND) + except subprocess.TimeoutExpired: + raise RuntimeError( + f"kubectl timed out after {timeout}s. " + "Check that your cluster is running and reachable, then try again." + ) + def get_namespaces() -> List[str]: + """Retrieve the sorted list of namespace names from the connected Kubernetes cluster via kubectl.""" try: - cmd = ["kubectl", "get", "namespaces", "-o", "json"] - proc = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=20) - + proc = _run_kubectl(["kubectl", "get", "namespaces", "-o", "json"], timeout=20) result = json.loads(proc.stdout) - namespaces = [item["metadata"]["name"] for item in result.get("items", [])] - - return sorted(namespaces) - # Gestion d'erreurs ici - except FileNotFoundError: - raise RuntimeError( - "kubectl is not installed or not in PATH. " - "Please install kubectl to use this feature." - ) - except subprocess.TimeoutExpired: - raise RuntimeError( - "Connection to Kubernetes cluster timed out. " - "Please check your cluster connectivity and try again." - ) + return sorted([item["metadata"]["name"] for item in result.get("items", [])]) except subprocess.CalledProcessError as e: error_msg = e.stderr.strip() if e.stderr else str(e) - - # Check for common error patterns - if "connect: no route to host" in error_msg or "dial tcp" in error_msg: - raise RuntimeError( - "Unable to connect to Kubernetes cluster. " - "Please ensure your cluster is running (e.g., 'minikube start') " - "and kubectl is configured correctly." - ) - elif "Unable to connect to the server" in error_msg: - raise RuntimeError( - "Kubernetes cluster is not accessible. " - "Please start your cluster (e.g., 'minikube start', 'kind create cluster') " - "or check your kubeconfig configuration." - ) - elif "connection refused" in error_msg.lower(): - raise RuntimeError( - "Connection to Kubernetes cluster was refused. " - "Please verify your cluster is running and accessible." - ) - else: - raise RuntimeError( - f"Failed to retrieve namespaces from cluster. " - f"Error: {error_msg[:200]}" - ) + _raise_connection_error(error_msg, f"kubectl error while fetching namespaces: {error_msg[:200]}") + except RuntimeError: + raise except json.JSONDecodeError as e: - raise RuntimeError(f"Failed to parse kubectl output: {str(e)}") + raise RuntimeError(f"Could not parse kubectl output: {str(e)}") except Exception as e: - raise RuntimeError(f"Unexpected error getting namespaces: {str(e)}") + raise RuntimeError(f"Unexpected error while fetching namespaces: {str(e)}") -def get_resource_types(namespace: Optional[str] = None) -> List[Dict[str, Any]]: +def get_resource_types() -> List[Dict[str, Any]]: """ - Get resource types that actually exist in the namespace/cluster. - Includes CRDs (Custom Resource Definitions) and all available resource types. - Only returns resources that have at least one instance to avoid empty diagrams. + Retrieve all resource types known by the cluster via kubectl api-resources. + Each entry includes name, shortNames, namespaced scope flag, and isCommon flag. + Common types exclude events and endpoints to avoid noisy intermediate resources. """ try: - # Common resource types to prioritize in display - common_types = { - 'pods', 'services', 'deployments', 'replicasets', 'statefulsets', - 'daemonsets', 'configmaps', 'secrets', 'ingresses', - 'persistentvolumeclaims', 'persistentvolumes', 'nodes', - 'namespaces', 'serviceaccounts', 'roles', 'rolebindings', - 'clusterroles', 'clusterrolebindings', 'jobs', 'cronjobs', - 'horizontalpodautoscalers', 'networkpolicies', 'storageclasses', - 'endpoints', 'events', 'limitranges', 'resourcequotas' - } + proc = _run_kubectl( + ["kubectl", "api-resources", "--verbs=list", "--no-headers"], timeout=30 + ) + resources = [] + seen = set() - # Without namespace, return only common types (no verification needed) - if not namespace: - return [ - {"name": name, "shortNames": [], "isCommon": True} - for name in sorted(common_types) - ] - - # Get ALL namespaced resources (including CRDs) - namespaced_cmd = ["kubectl", "api-resources", "--verbs=list", "--namespaced=true", "-o", "name"] - namespaced_proc = subprocess.run(namespaced_cmd, check=True, capture_output=True, text=True, timeout=20) - all_namespaced_resources = [r.strip() for r in namespaced_proc.stdout.strip().split('\n') if r.strip()] - - # Check which resources actually exist in the namespace - existing_resources = [] - seen_simple_names = set() - - # print(f"[DEBUG] Checking {len(all_namespaced_resources)} resource types in namespace '{namespace}'...") - - for resource_name in all_namespaced_resources: - # Extract simple name (e.g., "deployments" from "deployments.apps") - simple_name = resource_name.split('.')[0] if '.' in resource_name else resource_name - - # Skip if already checked - if simple_name in seen_simple_names: + for line in proc.stdout.strip().splitlines(): + tokens = line.split() + if len(tokens) < 4: continue - check_cmd = ["kubectl", "get", resource_name, "-n", namespace, "--no-headers", "--ignore-not-found"] - - try: - check_proc = subprocess.run( - check_cmd, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, # Ignore errors - text=True, - timeout=2 # Short timeout per resource - ) - - # If there's output and command succeeded, this resource exists - if check_proc.returncode == 0 and check_proc.stdout.strip(): - is_common = simple_name in common_types - existing_resources.append({ - "name": simple_name, - "shortNames": [], - "isCommon": is_common - }) - seen_simple_names.add(simple_name) - - except (subprocess.TimeoutExpired, Exception): - # Skip problematic resources + name = tokens[0] + # Deduplicate by simple name (e.g. "deployments" from "deployments.apps") + simple_name = name.split('.')[0] + if simple_name in seen: continue + seen.add(simple_name) - # Sort: common types first, then alphabetically - existing_resources.sort(key=lambda x: (not x['isCommon'], x['name'])) + # kubectl api-resources columns: NAME [SHORTNAMES] APIVERSION NAMESPACED KIND + # NAMESPACED is always second-to-last, KIND is last + namespaced = tokens[-2].lower() == 'true' + short_names = [tokens[1]] if len(tokens) >= 5 else [] - # print(f"[DEBUG] Found {len(existing_resources)} existing resources in namespace '{namespace}'") + resources.append({ + "name": simple_name, + "shortNames": short_names, + "namespaced": namespaced, + "isCommon": simple_name in COMMON_RESOURCE_TYPES, + }) - return existing_resources - - except FileNotFoundError: - raise RuntimeError( - "kubectl is not installed or not in PATH. " - "Please install kubectl to use this feature." - ) - except subprocess.TimeoutExpired: - raise RuntimeError( - "Connection to Kubernetes cluster timed out while fetching resource types." - ) + resources.sort(key=lambda x: (not x['isCommon'], x['name'])) + return resources except subprocess.CalledProcessError as e: error_msg = e.stderr.strip() if e.stderr else str(e) - - if "connect: no route to host" in error_msg or "dial tcp" in error_msg or "Unable to connect" in error_msg: - raise RuntimeError( - "Unable to connect to Kubernetes cluster. " - "Please ensure your cluster is running and accessible." - ) - else: - raise RuntimeError(f"Failed to get resource types: {error_msg[:200]}") + _raise_connection_error(error_msg, f"kubectl error while fetching resource types: {error_msg[:200]}") + except RuntimeError: + raise except Exception as e: - raise RuntimeError(f"Unexpected error getting resource types: {str(e)}") + raise RuntimeError(f"Unexpected error while fetching resource types: {str(e)}") + + +def get_current_context() -> str: + """Return the name of the currently active kubectl context.""" + try: + proc = _run_kubectl(["kubectl", "config", "current-context"], timeout=5) + return proc.stdout.strip() + except subprocess.CalledProcessError as e: + error_msg = e.stderr.strip() if e.stderr else str(e) + raise RuntimeError(f"No active kubectl context found: {error_msg}") + except RuntimeError: + raise + except Exception as e: + raise RuntimeError(f"Unexpected error while fetching context: {str(e)}") + + +def _make_diagrams_error(stdout: str, stderr: str, cmd: List[str]) -> DiagramResult: + """Return a DiagramResult describing why kubectl-diagrams failed.""" + if "Unable to connect" in stderr or "connect: no route to host" in stderr: + return DiagramResult( + success=False, + error="Cannot reach the Kubernetes API server. " + "Start your cluster (e.g. minikube start, kind create cluster) " + "and verify your kubeconfig with: kubectl config current-context", + command=" ".join(cmd), + stdout=stdout, + stderr=stderr, + ) + return DiagramResult( + success=False, + error="kubectl-diagrams failed. See command output below.", + command=" ".join(cmd), + stdout=stdout, + stderr=stderr, + ) -# Use kubectl-diagrams directly (combines kubectl get + kube-diagrams in one command) def generate_from_cluster( + resource_types: List[str], namespace: Optional[str] = None, - resource_types: Optional[List[str]] = None, all_namespaces: bool = False, output_format: str = "png", extra_args: str = "", without_namespace: bool = False ) -> DiagramResult: - """ - Generate diagram using kubectl-diagrams plugin directly. - This is more efficient than calling kubectl get + kube-diagrams separately. - """ + """Generate diagram using kubectl-diagrams plugin directly.""" + cmd: List[str] = [] try: - # Prepare resource types - if not resource_types or len(resource_types) == 0: - # Default resource types - resource_types = ['pods', 'services', 'deployments', 'replicasets', - 'statefulsets', 'daemonsets', 'configmaps', 'secrets', - 'ingresses', 'persistentvolumeclaims'] - - # Join resource types as comma-separated list resources_arg = ','.join(resource_types) - - # Generate unique output filename base_name = f"cluster-diagram-{uuid.uuid4().hex[:8]}" - output_dir = "/tmp" - output_file = f"{output_dir}/{base_name}.{output_format}" + base_path = os.path.join(tempfile.gettempdir(), base_name) + requested_output, png_output = FileManager.get_output_paths(base_path, output_format) - # Build kubectl-diagrams command cmd = ["kubectl-diagrams", resources_arg] - # Add namespace option if all_namespaces: cmd.append("--all-namespaces") elif namespace: cmd.extend(["-n", namespace]) - # Add output file - cmd.extend(["-o", output_file]) + cmd.extend(["-o", requested_output]) - # Add format if output_format != "png": cmd.extend(["-f", output_format]) - # Add without-namespace option if without_namespace: cmd.append("--without-namespace") - # Add extra args if provided if extra_args.strip(): cmd.extend(parse_extra_args(extra_args)) - # Execute command proc = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60) stdout_output = proc.stdout or "" stderr_output = proc.stderr or "" - # Check for errors if proc.returncode != 0 or has_fatal_error(stdout_output, stderr_output): - # Check if it's a connection error - if "Unable to connect" in stderr_output or "connect: no route to host" in stderr_output: - return DiagramResult( - success=False, - error="Unable to connect to Kubernetes cluster. Please ensure your cluster is running.", - command=" ".join(cmd), - stdout=stdout_output, - stderr=stderr_output - ) + return _make_diagrams_error(stdout_output, stderr_output, cmd) + output_info = FileManager.find_output_file(requested_output, png_output) + if output_info is None: return DiagramResult( success=False, - error="kubectl-diagrams failed. See command output below.", + error=f"Output file not found: {requested_output}", command=" ".join(cmd), stdout=stdout_output, stderr=stderr_output ) + output_file, produced_format = output_info - # Check if output file exists - if not os.path.exists(output_file): - # Try with .png extension if original format didn't work - png_output = f"{output_dir}/{base_name}.png" - if os.path.exists(png_output): - output_file = png_output - produced_format = "png" - else: - return DiagramResult( - success=False, - error=f"Output file not found: {output_file}", - command=" ".join(cmd), - stdout=stdout_output, - stderr=stderr_output - ) - else: - produced_format = output_format - - # Read the file content = FileManager.read_file_content(output_file, binary=True) encoded = encode_content(content, produced_format) - - # Clean up - try: - os.remove(output_file) - except: - pass + FileManager.cleanup_files(output_file) return DiagramResult( success=True, @@ -283,22 +238,20 @@ def generate_from_cluster( except FileNotFoundError: return DiagramResult( success=False, - error="kubectl-diagrams is not installed or not in PATH. Please install kubectl-diagrams plugin.", + error="kubectl-diagrams is not installed or not found in PATH. " + "Install the kubectl-diagrams plugin and ensure kubectl is configured: " + "kubectl config current-context", command="kubectl-diagrams" ) except subprocess.TimeoutExpired: return DiagramResult( success=False, error="Command timed out. The cluster might be slow or unresponsive.", - command=" ".join(cmd) if 'cmd' in locals() else "kubectl-diagrams" + command=" ".join(cmd) or "kubectl-diagrams" ) except Exception as e: return DiagramResult( success=False, error=f"Unexpected error: {str(e)}", - command=" ".join(cmd) if 'cmd' in locals() else "kubectl-diagrams" - ) - - - - + command=" ".join(cmd) or "kubectl-diagrams" + ) \ No newline at end of file diff --git a/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx b/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx index e5ff687..730c746 100644 --- a/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx +++ b/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx @@ -1,23 +1,19 @@ /** * Cluster Input Component - * Handles cluster resource selection and diagram generation options + * Stateless presentational component for cluster resource selection and diagram options. + * All state and handlers are provided by ClusterTab/index.jsx via useClusterData. */ -import { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Info, Server, RefreshCw, Layers } from 'lucide-react'; -import { toast } from 'sonner'; +import { Info, Server, RefreshCw, Layers, Search } from 'lucide-react'; import SubmitButton from '../../common/SubmitButton.jsx'; import ManifestOptions from '../../options/ManifestOptions'; -import { getClusterNamespaces, getClusterResourceTypes } from '../../../services/diagramApi.js'; function ClusterInput({ + // Input state (from ClusterTab/index.jsx) namespace, - setNamespace, resourceTypes, - setResourceTypes, allNamespaces, - setAllNamespaces, outputFormat, setOutputFormat, extraArgs, @@ -25,127 +21,29 @@ function ClusterInput({ withoutNamespace, setWithoutNamespace, errorMessage, - setErrorMessage, isSubmitting, onSubmit, + // Cluster data (from useClusterData via index.jsx) + currentContext, + namespaces, + availableResourceTypes, + loadingNamespaces, + loadingResourceTypes, + resourceTypeSearch, + setResourceTypeSearch, + filteredResourceTypes, + commonVisible, + otherVisible, + fetchContext, + fetchNamespaces, + handleRefreshResourceTypes, + handleResourceTypeToggle, + handleSelectCommon, + handleSelectAll, + handleClearSelection, + handleAllNamespacesToggle, + handleNamespaceChange, }) { - const [namespaces, setNamespaces] = useState([]); - const [availableResourceTypes, setAvailableResourceTypes] = useState([]); - const [loadingNamespaces, setLoadingNamespaces] = useState(false); - const [loadingResourceTypes, setLoadingResourceTypes] = useState(false); - - // Fetch namespaces - useEffect(() => { - fetchNamespaces(); - }, []); - - // Fetch resource types when namespace changes - useEffect(() => { - if (namespace || allNamespaces) { - fetchResourceTypes(); - } - }, [namespace, allNamespaces]); - - const fetchNamespaces = async () => { - setLoadingNamespaces(true); - try { - const response = await getClusterNamespaces(); - if (response.ok && response.data?.namespaces) { - setNamespaces(response.data.namespaces); - } else { - const errorMsg = response.data?.error || 'Unknown error'; - - if ( - errorMsg.includes('Unable to connect') || - errorMsg.includes('not running') || - errorMsg.includes('not accessible') - ) { - toast.error('Cluster not accessible', { - description: errorMsg, - duration: 10000, - action: { - label: 'Help', - onClick: () => { - // Could open a modal with troubleshooting steps - alert( - 'Please ensure your Kubernetes cluster is running:\n\n' + - '- For minikube: run "minikube start"\n' + - 'Then refresh this page and try again.\n\n' + - 'See TROUBLESHOOTING_CLUSTER.md for more details.' - ); - }, - }, - }); - } else { - toast.error('Failed to fetch namespaces', { - description: errorMsg, - duration: 8000, - }); - } - } - } catch (error) { - toast.error('Network error', { - description: 'Could not connect to the backend. Please ensure the backend is running.', - duration: 5000, - }); - } finally { - setLoadingNamespaces(false); - } - }; - - const fetchResourceTypes = async () => { - setLoadingResourceTypes(true); - try { - // Pass the current namespace to get resources specific to it - const response = await getClusterResourceTypes(allNamespaces ? null : namespace); - if (response.ok && response.data?.resourceTypes) { - setAvailableResourceTypes(response.data.resourceTypes); - } else { - const errorMsg = response.data?.error || 'Unknown error'; - - // Only show error if it's not a connection error (already shown for namespaces) - if (!errorMsg.includes('Unable to connect') && !errorMsg.includes('not running')) { - toast.error('Failed to fetch resource types', { - description: errorMsg, - duration: 5000, - }); - } - } - } catch (error) { - // Silent fail to avoid duplicate error toasts - console.error('Failed to fetch resource types:', error); - } finally { - setLoadingResourceTypes(false); - } - }; - - const handleResourceTypeToggle = (type) => { - setResourceTypes((prev) => { - if (prev.includes(type)) { - return prev.filter((t) => t !== type); - } else { - return [...prev, type]; - } - }); - }; - - const handleSelectAllCommon = () => { - // Select all common resource types from the dynamic list - const commonTypes = availableResourceTypes.filter((rt) => rt.isCommon).map((rt) => rt.name); - setResourceTypes(commonTypes); - }; - - const handleClearSelection = () => { - setResourceTypes([]); - }; - - const handleAllNamespacesToggle = (checked) => { - setAllNamespaces(checked); - if (checked) { - setNamespace(''); - } - }; - return (
@@ -153,6 +51,13 @@ function ClusterInput({

Cluster Resources

+ {currentContext && ( +

+ Context:{' '} + {currentContext} +

+ )} +
{/* All Namespaces Checkbox */}
@@ -175,7 +80,7 @@ function ClusterInput({
+ )} +
{loadingResourceTypes ? (
- - Loading resource types - {namespace - ? ` for namespace "${namespace}"` - : allNamespaces - ? ' from all namespaces' - : ''} - ... - + Loading resource types from cluster… +
+ ) : filteredResourceTypes.length === 0 && resourceTypeSearch ? ( +
+

+ No resource types match{' '} + "{resourceTypeSearch}". +

) : availableResourceTypes.length === 0 ? (

- {!namespace && !allNamespaces - ? 'Please select a namespace first to load available resource types.' - : 'No resource types available. Please check your cluster connection.'} + No resource types available. Please check your cluster connection.

- {(namespace || allNamespaces) && ( - - )} +
) : ( <> - {/* Common Resource Types */} - {availableResourceTypes.filter((rt) => rt.isCommon).length > 0 && ( + {commonVisible.length > 0 && (

Common Resources - {namespace && ( - ({namespace}) - )}

- {availableResourceTypes - .filter((rt) => rt.isCommon) - .map((rt) => ( - - ))} + {commonVisible.map((rt) => ( + + ))}
)} - - {/* Other Resource Types */} - {availableResourceTypes.filter((rt) => !rt.isCommon).length > 0 && ( + {otherVisible.length > 0 && (

Other Resources - {namespace && ( - ({namespace}) - )}

- {availableResourceTypes - .filter((rt) => !rt.isCommon) - .map((rt) => ( - - ))} + {otherVisible.map((rt) => ( + + ))}
)} )}
-

- Select resource types to include in the diagram. Leave empty for default selection. - {(namespace || allNamespaces) && ( - - )} +

+ {resourceTypes.length > 0 + ? `${resourceTypes.length} type${resourceTypes.length > 1 ? 's' : ''} selected` + : 'No types selected — please select at least one resource type.'}

@@ -385,7 +282,7 @@ function ClusterInput({ {isSubmitting ? 'Generating…' : 'Generate Cluster Diagram'} @@ -408,17 +305,24 @@ function ClusterInput({ {namespaces.length === 0 ? ( <> - Cluster not connected. Please start your Kubernetes cluster: -
- For minikube:{' '} + No cluster detected. Start your cluster, then click{' '} + Refresh: +
• minikube:{' '} minikube start -
Then click the Refresh button above. See{' '} - TROUBLESHOOTING_CLUSTER.md for more help. +
• kind:{' '} + kind create cluster +
• k3d:{' '} + k3d cluster create +
+ Make sure kubectl is installed + and your kubeconfig points to the right context. ) : ( <> - This will retrieve resources from your Kubernetes cluster using kubectl and - generate a diagram. Make sure you have proper cluster access configured via - kubeconfig. + Retrieves resources from your cluster via kubectl and generates a diagram. Make + sure kubectl is configured with the correct context ( + kubectl config current-context + ). )}
@@ -429,13 +333,49 @@ function ClusterInput({ ); } +function ResourceTypeItem({ rt, checked, onToggle }) { + return ( + + ); +} + +ResourceTypeItem.propTypes = { + rt: PropTypes.shape({ + name: PropTypes.string.isRequired, + shortNames: PropTypes.arrayOf(PropTypes.string), + namespaced: PropTypes.bool.isRequired, + }).isRequired, + checked: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, +}; + ClusterInput.propTypes = { namespace: PropTypes.string.isRequired, - setNamespace: PropTypes.func.isRequired, resourceTypes: PropTypes.arrayOf(PropTypes.string).isRequired, - setResourceTypes: PropTypes.func.isRequired, allNamespaces: PropTypes.bool.isRequired, - setAllNamespaces: PropTypes.func.isRequired, outputFormat: PropTypes.string.isRequired, setOutputFormat: PropTypes.func.isRequired, extraArgs: PropTypes.string.isRequired, @@ -443,9 +383,27 @@ ClusterInput.propTypes = { withoutNamespace: PropTypes.bool.isRequired, setWithoutNamespace: PropTypes.func.isRequired, errorMessage: PropTypes.string, - setErrorMessage: PropTypes.func.isRequired, isSubmitting: PropTypes.bool.isRequired, onSubmit: PropTypes.func.isRequired, + currentContext: PropTypes.string.isRequired, + namespaces: PropTypes.arrayOf(PropTypes.string).isRequired, + availableResourceTypes: PropTypes.array.isRequired, + loadingNamespaces: PropTypes.bool.isRequired, + loadingResourceTypes: PropTypes.bool.isRequired, + resourceTypeSearch: PropTypes.string.isRequired, + setResourceTypeSearch: PropTypes.func.isRequired, + filteredResourceTypes: PropTypes.array.isRequired, + commonVisible: PropTypes.array.isRequired, + otherVisible: PropTypes.array.isRequired, + fetchContext: PropTypes.func.isRequired, + fetchNamespaces: PropTypes.func.isRequired, + handleRefreshResourceTypes: PropTypes.func.isRequired, + handleResourceTypeToggle: PropTypes.func.isRequired, + handleSelectCommon: PropTypes.func.isRequired, + handleSelectAll: PropTypes.func.isRequired, + handleClearSelection: PropTypes.func.isRequired, + handleAllNamespacesToggle: PropTypes.func.isRequired, + handleNamespaceChange: PropTypes.func.isRequired, }; -export default ClusterInput; +export default ClusterInput; \ No newline at end of file diff --git a/webapp/frontend/src/components/tabs/ClusterTab/index.jsx b/webapp/frontend/src/components/tabs/ClusterTab/index.jsx index 5abd763..f25147d 100644 --- a/webapp/frontend/src/components/tabs/ClusterTab/index.jsx +++ b/webapp/frontend/src/components/tabs/ClusterTab/index.jsx @@ -8,6 +8,7 @@ import { DEFAULTS } from '../../../utils/constants.js'; import { generateClusterDiagram } from '../../../services/diagramApi.js'; import { useViewerSync } from '../../../hooks/useViewerSync.js'; import { useDiagramGeneration } from '../../../hooks/useDiagramGeneration.js'; +import { useClusterData } from '../../../hooks/useClusterData.js'; import ClusterInput from './ClusterInput.jsx'; import ClusterOutput from './ClusterOutput.jsx'; import CommandDetails from '../../common/CommandDetails.jsx'; @@ -34,7 +35,6 @@ function ClusterTab({ historyContext }) { setErrorMessage, isSubmitting, viewerKey, - progressStep, handleSubmit: generateDiagram, resetOutput, } = useDiagramGeneration({ @@ -61,9 +61,37 @@ function ClusterTab({ historyContext }) { }, [outputFormat, diagram, resetOutput]); // Viewer synchronization hook for DOT_JSON format - const { viewerRef, handleViewerLoad } = useViewerSync({ - diagram, - outputFormat, + const { viewerRef, handleViewerLoad } = useViewerSync({ diagram, outputFormat }); + + // Cluster connectivity: namespaces, resource types, context, and all related handlers + const { + currentContext, + namespaces, + availableResourceTypes, + loadingNamespaces, + loadingResourceTypes, + resourceTypeSearch, + setResourceTypeSearch, + filteredResourceTypes, + commonVisible, + otherVisible, + fetchContext, + fetchNamespaces, + handleRefreshResourceTypes, + handleResourceTypeToggle, + handleSelectCommon, + handleSelectAll, + handleClearSelection, + handleAllNamespacesToggle, + handleNamespaceChange, + } = useClusterData({ + resourceTypes, + setResourceTypes, + namespace, + setNamespace, + allNamespaces, + setAllNamespaces, + setErrorMessage, }); // Auto-scroll to output when diagram is ready @@ -136,11 +164,8 @@ function ClusterTab({ historyContext }) {
diff --git a/webapp/frontend/src/hooks/useClusterData.js b/webapp/frontend/src/hooks/useClusterData.js new file mode 100644 index 0000000..fe130a2 --- /dev/null +++ b/webapp/frontend/src/hooks/useClusterData.js @@ -0,0 +1,229 @@ +import { useState, useEffect, useRef } from 'react'; +import { toast } from 'sonner'; +import { + getClusterContext, + getClusterNamespaces, + getClusterResourceTypes, +} from '../services/diagramApi.js'; + +/** + * Manages cluster connectivity state: namespaces, resource types, and active context. + * Handles all fetch logic, auto-selection, and resource-type selection handlers. + * + * @param {Object} params + * @param {string[]} params.resourceTypes - Currently selected resource type names + * @param {Function} params.setResourceTypes + * @param {string} params.namespace - Currently selected namespace + * @param {Function} params.setNamespace + * @param {boolean} params.allNamespaces + * @param {Function} params.setAllNamespaces + * @param {Function} params.setErrorMessage - Called with '' when namespace changes + */ +export function useClusterData({ + resourceTypes, + setResourceTypes, + namespace, + setNamespace, + allNamespaces, + setAllNamespaces, + setErrorMessage, +}) { + const [currentContext, setCurrentContext] = useState(''); + const [namespaces, setNamespaces] = useState([]); + const [availableResourceTypes, setAvailableResourceTypes] = useState([]); + const [loadingNamespaces, setLoadingNamespaces] = useState(false); + const [loadingResourceTypes, setLoadingResourceTypes] = useState(false); + const [resourceTypeSearch, setResourceTypeSearch] = useState(''); + const resourceTypesAutoSelectedRef = useRef(false); + + useEffect(() => { + fetchContext(); + fetchNamespaces(); + fetchResourceTypes(); + }, []); + + // Auto-select common resources the first time the list loads + useEffect(() => { + if (availableResourceTypes.length > 0 && !resourceTypesAutoSelectedRef.current) { + resourceTypesAutoSelectedRef.current = true; + if (resourceTypes.length === 0) { + setResourceTypes(availableResourceTypes.filter((rt) => rt.isCommon).map((rt) => rt.name)); + } + } + }, [availableResourceTypes, resourceTypes, setResourceTypes]); + + const fetchContext = async () => { + try { + const response = await getClusterContext(); + if (response.ok && response.data?.context) { + setCurrentContext(response.data.context); + } + } catch { + // Silent — context is informational, not blocking + } + }; + + const fetchNamespaces = async () => { + setLoadingNamespaces(true); + try { + const response = await getClusterNamespaces(); + if (response.ok && response.data?.namespaces) { + setNamespaces(response.data.namespaces); + } else { + const errorMsg = response.data?.error || 'Unknown error'; + if ( + errorMsg.includes('Unable to connect') || + errorMsg.includes('not running') || + errorMsg.includes('not accessible') || + errorMsg.includes('timed out') || + errorMsg.includes('refused') + ) { + toast.error('Cluster not reachable', { + description: + 'kubectl cannot connect to your cluster. ' + + 'Start it first (e.g. minikube start, kind create cluster, k3d cluster create) ' + + 'then click Refresh.', + duration: 10000, + }); + } else { + toast.error('Failed to fetch namespaces', { description: errorMsg, duration: 8000 }); + } + } + } catch { + toast.error('Network error', { + description: 'Could not connect to the backend. Please ensure the backend is running.', + duration: 5000, + }); + } finally { + setLoadingNamespaces(false); + } + }; + + const fetchResourceTypes = async () => { + setLoadingResourceTypes(true); + try { + const response = await getClusterResourceTypes(); + if (response.ok && response.data?.resourceTypes) { + setAvailableResourceTypes(response.data.resourceTypes); + } else { + const errorMsg = response.data?.error || 'Unknown error'; + // Suppress duplicate connectivity toasts — already shown by fetchNamespaces + if ( + !errorMsg.includes('Unable to connect') && + !errorMsg.includes('not running') && + !errorMsg.includes('timed out') + ) { + toast.error('Failed to fetch resource types', { description: errorMsg, duration: 5000 }); + } + } + } catch { + toast.error('Network error', { + description: 'Could not reach the backend. Please ensure the backend is running.', + duration: 5000, + }); + } finally { + setLoadingResourceTypes(false); + } + }; + + const handleResourceTypeToggle = (type) => { + setResourceTypes((prev) => + prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type] + ); + }; + + const handleSelectCommon = () => { + const commons = availableResourceTypes.filter((rt) => rt.isCommon); + // With a specific namespace, default to namespaced commons only — cluster-scoped + // resources (nodes, storageclasses…) can still be added manually. + const selected = + namespace && !allNamespaces ? commons.filter((rt) => rt.namespaced) : commons; + setResourceTypes(selected.map((rt) => rt.name)); + }; + + const handleSelectAll = () => { + setResourceTypes(availableResourceTypes.map((rt) => rt.name)); + }; + + const handleClearSelection = () => { + setResourceTypes([]); + }; + + const handleRefreshResourceTypes = () => { + setResourceTypeSearch(''); + fetchResourceTypes(); + }; + + const handleAllNamespacesToggle = (checked) => { + setAllNamespaces(checked); + if (checked) { + setNamespace(''); + // Restore cluster-scoped commons removed when switching to a specific namespace + if (availableResourceTypes.length > 0) { + const clusterScopedCommons = availableResourceTypes + .filter((rt) => rt.isCommon && !rt.namespaced) + .map((rt) => rt.name); + setResourceTypes((prev) => { + const existing = new Set(prev); + const toAdd = clusterScopedCommons.filter((name) => !existing.has(name)); + return toAdd.length > 0 ? [...prev, ...toAdd] : prev; + }); + } + } + }; + + const handleNamespaceChange = (newNamespace) => { + setNamespace(newNamespace); + if (setErrorMessage) setErrorMessage(''); + // Switching to a specific namespace: remove cluster-scoped resources from the current + // selection — they are not bound to a namespace. + if (newNamespace && availableResourceTypes.length > 0) { + const namespacedNames = new Set( + availableResourceTypes.filter((rt) => rt.namespaced).map((rt) => rt.name) + ); + setResourceTypes((prev) => prev.filter((name) => namespacedNames.has(name))); + } + }; + + // Derived display state + const filteredResourceTypes = resourceTypeSearch + ? availableResourceTypes.filter((rt) => + rt.name.toLowerCase().includes(resourceTypeSearch.toLowerCase()) + ) + : availableResourceTypes; + + const isNamespaceContext = Boolean(namespace && !allNamespaces); + const commonVisible = filteredResourceTypes.filter( + (rt) => rt.isCommon && (!isNamespaceContext || rt.namespaced) + ); + const otherVisible = filteredResourceTypes.filter( + (rt) => !rt.isCommon || (isNamespaceContext && !rt.namespaced) + ); + + return { + // Data + currentContext, + namespaces, + availableResourceTypes, + // Loading flags + loadingNamespaces, + loadingResourceTypes, + // Search / filtered views + resourceTypeSearch, + setResourceTypeSearch, + filteredResourceTypes, + commonVisible, + otherVisible, + // Fetch actions + fetchContext, + fetchNamespaces, + handleRefreshResourceTypes, + // Selection handlers + handleResourceTypeToggle, + handleSelectCommon, + handleSelectAll, + handleClearSelection, + handleAllNamespacesToggle, + handleNamespaceChange, + }; +} \ No newline at end of file diff --git a/webapp/frontend/src/services/diagramApi.js b/webapp/frontend/src/services/diagramApi.js index 2de36b5..bdc6b5b 100644 --- a/webapp/frontend/src/services/diagramApi.js +++ b/webapp/frontend/src/services/diagramApi.js @@ -199,6 +199,32 @@ export async function generateClusterDiagram({ return response; } +/** + * Get the currently active kubectl context name. + * @returns {Promise} Response with context name + */ +export async function getClusterContext() { + logger.debug('Fetching current kubectl context'); + + try { + const response = await fetch(API_ENDPOINTS.CLUSTER_CONTEXT, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + const data = await response.json(); + + if (response.ok) { + logger.info('kubectl context fetched', { context: data?.context }); + } + + return { ok: response.ok, status: response.status, data }; + } catch (error) { + logger.error('Network error fetching kubectl context', { error }); + throw error; + } +} + /** * Get list of namespaces from Kubernetes cluster * @returns {Promise} Response with namespaces list @@ -239,19 +265,17 @@ export async function getClusterNamespaces() { } /** - * Get list of resource types from Kubernetes cluster - * @param {string} [namespace] - Optional namespace filter + * Get all resource types known by the Kubernetes cluster. + * Each entry includes name, shortNames, namespaced scope, and isCommon flag. + * The list is fetched once on tab open and cached in the component — use the + * refresh button to re-query the cluster. * @returns {Promise} Response with resource types list */ -export async function getClusterResourceTypes(namespace = '') { - logger.debug('Fetching cluster resource types', { namespace }); +export async function getClusterResourceTypes() { + logger.debug('Fetching cluster resource types'); try { - const url = namespace - ? `${API_ENDPOINTS.CLUSTER_RESOURCE_TYPES}?namespace=${encodeURIComponent(namespace)}` - : API_ENDPOINTS.CLUSTER_RESOURCE_TYPES; - - const response = await fetch(url, { + const response = await fetch(API_ENDPOINTS.CLUSTER_RESOURCE_TYPES, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -282,35 +306,6 @@ export async function getClusterResourceTypes(namespace = '') { } } -/** - * Retrieve resources from Kubernetes cluster - * @param {Object} params - Request parameters - * @param {string} [params.namespace] - Namespace filter - * @param {Array} [params.resourceTypes] - Resource types to retrieve - * @param {boolean} [params.allNamespaces] - Retrieve from all namespaces - * @returns {Promise} Response with manifest data - */ -export async function getClusterResources({ - namespace = '', - resourceTypes = [], - allNamespaces = false, -}) { - logger.debug('Retrieving cluster resources', { namespace, resourceTypes, allNamespaces }); - - const response = await apiFetch(API_ENDPOINTS.CLUSTER_RESOURCES, { - body: JSON.stringify({ - namespace, - resourceTypes, - allNamespaces, - }), - }); - - if (response.ok && response.data.manifest) { - logger.info('Cluster resources retrieved successfully'); - } - - return response; -} /** * Submit user feedback @@ -362,9 +357,9 @@ export default { generateHelmDiagram, generateHelmfileDiagram, generateClusterDiagram, + getClusterContext, getClusterNamespaces, getClusterResourceTypes, - getClusterResources, submitFeedback, hasFatalErrors, }; diff --git a/webapp/frontend/src/utils/constants.js b/webapp/frontend/src/utils/constants.js index f015309..374aa3c 100644 --- a/webapp/frontend/src/utils/constants.js +++ b/webapp/frontend/src/utils/constants.js @@ -28,9 +28,9 @@ export const API_ENDPOINTS = Object.freeze({ GENERATE_HELM: '/api/generate-helm-diagram', GENERATE_HELMFILE: '/api/generate-helmfile-diagram', GENERATE_CLUSTER: '/api/cluster/generate', + CLUSTER_CONTEXT: '/api/cluster/context', CLUSTER_NAMESPACES: '/api/cluster/namespaces', CLUSTER_RESOURCE_TYPES: '/api/cluster/resource-types', - CLUSTER_RESOURCES: '/api/cluster/resources', SUBMIT_FEEDBACK: '/api/submit-feedback', EXAMPLES: '/api/examples', });