From e1e84919a447ec8a1d272132143f4a3d1a65478e Mon Sep 17 00:00:00 2001 From: Sadallah Date: Mon, 1 Jun 2026 15:37:51 +0200 Subject: [PATCH 1/4] feat(webapp): add cluster tab --- webapp/README.md | 29 +- webapp/backend/app.py | 2 + webapp/backend/requirements.txt | 1 - webapp/backend/routes/cluster.py | 104 ++++ webapp/backend/services/__init__.py | 10 +- webapp/backend/services/clusterService.py | 304 ++++++++++++ webapp/backend/utils/validators.py | 18 + .../frontend/src/components/common/Tabs.jsx | 4 + .../tabs/ClusterTab/ClusterInput.jsx | 451 ++++++++++++++++++ .../tabs/ClusterTab/ClusterOutput.jsx | 58 +++ .../src/components/tabs/ClusterTab/index.jsx | 188 ++++++++ webapp/frontend/src/services/diagramApi.js | 167 +++++++ webapp/frontend/src/utils/constants.js | 4 + 13 files changed, 1332 insertions(+), 8 deletions(-) create mode 100644 webapp/backend/routes/cluster.py create mode 100644 webapp/backend/services/clusterService.py create mode 100644 webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx create mode 100644 webapp/frontend/src/components/tabs/ClusterTab/ClusterOutput.jsx create mode 100644 webapp/frontend/src/components/tabs/ClusterTab/index.jsx diff --git a/webapp/README.md b/webapp/README.md index 1a1b672..9d321c0 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -4,9 +4,9 @@ Web Interface for generating Kubernetes diagrams from manifests, Helm charts, or A modern web application for generating Kubernetes architecture diagrams from manifests, Helm charts, or Helmfile configurations using [KubeDiagrams](https://github.com/philippemerle/KubeDiagrams). -## ✨ Features +## Features -- **Multiple Input Types**: Support for Kubernetes manifests, Helm charts, and Helmfile configurations +- **Multiple Input Types**: Support for Kubernetes manifests, Helm charts, Helmfile configurations, and live cluster diagrams - **Flexible Output Formats**: Generate diagrams in PNG, SVG, PDF, DOT, and interactive HTML - **Interactive Viewer**: Explore diagrams with an interactive web viewer - **Built-in Examples**: Pre-loaded examples for quick testing @@ -75,11 +75,13 @@ webapp/ │ │ ├── manifest.py # Manifest diagram generation endpoints │ │ ├── helm.py # Helm chart diagram endpoints │ │ ├── helmfile.py # Helmfile diagram endpoints +│ │ ├── cluster.py # Cluster diagram endpoints │ │ └── submit.py # Feedback submission endpoint │ ├── services/ # Business logic layer │ │ ├── manifestService.py # Manifest processing service │ │ ├── helmService.py # Helm processing service │ │ ├── helmfileService.py # Helmfile processing service +│ │ ├── clusterService.py # Cluster processing service │ │ ├── file_manager.py # File operations manager │ │ └── models.py # Data models │ ├── utils/ # Utility modules @@ -103,6 +105,7 @@ webapp/ │ │ │ ├── ManifestTab/ # Kubernetes manifest tab │ │ │ ├── HelmTab/ # Helm chart tab │ │ │ ├── HelmFileTab/ # Helmfile tab +│ │ │ ├── ClusterTab/ # Live cluster tab │ │ │ └── InteractiveViewerTab/ # Interactive viewer │ │ ├── examples/ # Example registry │ │ ├── hooks/ # Custom React hooks @@ -311,9 +314,22 @@ Generate diagrams from Helmfile configurations. 4. Click "Generate Diagram" **Requirement**: `helmfile` must be installed on the server. - -### 4. Interactive Viewer - +5. +### 4. Cluster Tab +Generate diagrams from a live Kubernetes cluster. +**Steps**: +1. Select the "Cluster" tab +2. Optionally specify a Kubernetes context (leave empty to use current context) +3. Optionally specify a namespace (leave empty to diagram all namespaces) +4. Configure diagram options +5. Click "Generate Cluster Diagram" +**Requirements**: +- `kubectl-diagrams` must be installed on the server +- Server must have access to a Kubernetes cluster via kubeconfig +- Proper RBAC permissions to read cluster resources +**Note**: This uses the server's kubectl configuration, not the client's. +6. +### 5. Interactive Viewer View diagrams in an interactive HTML viewer with zoom, pan, and search capabilities. **Features**: @@ -371,6 +387,7 @@ See `frontend/public/examples/README.md` for instructions on adding new examples - `POST /api/manifest/generate` - Generate diagram from manifest - `POST /api/helm/generate` - Generate diagram from Helm chart - `POST /api/helmfile/generate` - Generate diagram from Helmfile +- `POST /api/cluster/generate` - Generate diagram from live cluster - `POST /api/submit` - Submit feedback --- @@ -458,5 +475,5 @@ For issues and questions, please [open an issue on the GitHub repository](https: --- -## 🙏 Acknowledgments +## Acknowledgments - [Graphviz](https://graphviz.org/) - Graph visualization software diff --git a/webapp/backend/app.py b/webapp/backend/app.py index 09d154c..e14ef2a 100644 --- a/webapp/backend/app.py +++ b/webapp/backend/app.py @@ -7,6 +7,7 @@ from routes.manifest import manifest_bp from routes.helm import helm_bp from routes.helmfile import helmfile_bp from routes.submit import submit_bp +from routes.cluster import cluster_bp from utils.access_logger import log_request, get_real_ip, get_all_ip_headers from time import time @@ -89,6 +90,7 @@ def create_app(): app.register_blueprint(helm_bp) app.register_blueprint(helmfile_bp) app.register_blueprint(submit_bp) + app.register_blueprint(cluster_bp) return app diff --git a/webapp/backend/requirements.txt b/webapp/backend/requirements.txt index 93b9ef2..ce610e5 100644 --- a/webapp/backend/requirements.txt +++ b/webapp/backend/requirements.txt @@ -16,7 +16,6 @@ MarkupSafe==3.0.3 nodeenv==1.10.0 platformdirs==4.9.4 pre_commit==4.5.1 -puremagic==2.0.2 pygraphviz==1.14 python-discovery==1.1.3 PyYAML==6.0.3 diff --git a/webapp/backend/routes/cluster.py b/webapp/backend/routes/cluster.py new file mode 100644 index 0000000..ac7a706 --- /dev/null +++ b/webapp/backend/routes/cluster.py @@ -0,0 +1,104 @@ +"""Routes for diagram generation from live Kubernetes cluster.""" +from flask import Blueprint, request + +from .utils import log_to_csv, compact_for_log +from services import ( + generate_from_cluster, + get_namespaces, + get_resource_types, +) +from utils import InputValidator, ResponseBuilder + +cluster_bp = Blueprint('cluster', __name__) + + +@cluster_bp.route('/api/cluster/namespaces', methods=['GET']) +def list_namespaces(): + try: + namespaces = get_namespaces() + return ResponseBuilder.success({ + "namespaces": namespaces, + "count": len(namespaces) + }) + 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/resource-types', methods=['GET']) +def list_resource_types(): + namespace = request.args.get('namespace') + try: + resource_types = get_resource_types(namespace=namespace) + return ResponseBuilder.success({ + "resourceTypes": resource_types, + "count": len(resource_types) + }) + 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/generate', methods=['POST']) +def generate_cluster_diagram(): + """Generate a diagram from live Kubernetes cluster resources using kubectl-diagrams.""" + data = request.get_json() + + namespace = data.get('namespace') + resource_types = data.get('resourceTypes', []) + all_namespaces = data.get('allNamespaces', False) + output_format = (data.get('outputFormat') or 'png').lower() + extra_args = data.get('extraArgs', '') + without_namespace = data.get('withoutNamespace', False) + + # Log to CSV + client_ip = request.remote_addr + route = request.path + params = ( + f"namespace={namespace};" + f"resourceTypes={','.join(resource_types) if resource_types else 'default'};" + f"allNamespaces={all_namespaces};" + f"format={output_format};" + f"extraArgs={compact_for_log(extra_args)};" + f"withoutNamespace={without_namespace}" + ) + log_to_csv(client_ip, route, params) + + # Validate namespace if provided + if namespace and not InputValidator.validate_k8s_name(namespace): + return ResponseBuilder.validation_error("namespace", "Invalid namespace name") + + # Output format validation + is_valid, error_msg = InputValidator.validate_output_format(output_format) + if not is_valid: + return ResponseBuilder.validation_error("outputFormat", error_msg) + + # Extra arguments validation + is_valid, error_msg = InputValidator.validate_extra_args(extra_args) + if not is_valid: + return ResponseBuilder.validation_error("extraArgs", error_msg) + + # Generate diagram using kubectl-diagrams + result = generate_from_cluster( + namespace=namespace, + resource_types=resource_types if resource_types else None, + all_namespaces=all_namespaces, + output_format=output_format, + extra_args=extra_args, + without_namespace=without_namespace + ) + + if result.success: + return ResponseBuilder.success(result.to_dict()) + else: + return ResponseBuilder.error( + result.error, + details={ + "command": result.command, + "stdout": result.stdout, + "stderr": result.stderr + } + ) + diff --git a/webapp/backend/services/__init__.py b/webapp/backend/services/__init__.py index 0003b65..08c2406 100644 --- a/webapp/backend/services/__init__.py +++ b/webapp/backend/services/__init__.py @@ -4,11 +4,19 @@ from .file_manager import FileManager from .manifestService import generate_from_manifest from .helmService import generate_from_helm from .helmfileService import generate_from_helmfile +from .clusterService import ( + generate_from_cluster, + get_namespaces, + get_resource_types, +) __all__ = [ 'DiagramResult', 'FileManager', 'generate_from_manifest', 'generate_from_helm', - 'generate_from_helmfile' + 'generate_from_helmfile', + 'generate_from_cluster', + 'get_namespaces', + 'get_resource_types', ] diff --git a/webapp/backend/services/clusterService.py b/webapp/backend/services/clusterService.py new file mode 100644 index 0000000..0d8844a --- /dev/null +++ b/webapp/backend/services/clusterService.py @@ -0,0 +1,304 @@ +"""Service for generating diagrams from live Kubernetes cluster resources.""" +import subprocess +import json +import os +import uuid +from typing import List, Optional, Dict, Any + +from constants import MIME_TYPES +from .models import DiagramResult +from .file_manager import FileManager +from .utils import parse_extra_args, has_fatal_error, encode_content + + +def get_namespaces() -> List[str]: + try: + cmd = ["kubectl", "get", "namespaces", "-o", "json"] + proc = subprocess.run(cmd, check=True, capture_output=True, text=True, 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." + ) + 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]}" + ) + except json.JSONDecodeError as e: + raise RuntimeError(f"Failed to parse kubectl output: {str(e)}") + except Exception as e: + raise RuntimeError(f"Unexpected error getting namespaces: {str(e)}") + + +def get_resource_types(namespace: Optional[str] = None) -> 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. + """ + 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' + } + + # 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: + 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 + continue + + # Sort: common types first, then alphabetically + existing_resources.sort(key=lambda x: (not x['isCommon'], x['name'])) + + # print(f"[DEBUG] Found {len(existing_resources)} existing resources in namespace '{namespace}'") + + 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." + ) + 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]}") + except Exception as e: + raise RuntimeError(f"Unexpected error getting resource types: {str(e)}") + + +# Use kubectl-diagrams directly (combines kubectl get + kube-diagrams in one command) +def generate_from_cluster( + 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. + """ + 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}" + + # 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]) + + # 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 DiagramResult( + success=False, + error="kubectl-diagrams failed. See command output below.", + command=" ".join(cmd), + stdout=stdout_output, + stderr=stderr_output + ) + + # 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 + + return DiagramResult( + success=True, + diagram=encoded, + mime_type=MIME_TYPES.get(produced_format, "application/octet-stream"), + filename=f"{base_name}.{produced_format}", + message="Diagram successfully generated from cluster resources using kubectl-diagrams.", + command=" ".join(cmd), + stdout=stdout_output, + stderr=stderr_output + ) + + except FileNotFoundError: + return DiagramResult( + success=False, + error="kubectl-diagrams is not installed or not in PATH. Please install kubectl-diagrams plugin.", + 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" + ) + except Exception as e: + return DiagramResult( + success=False, + error=f"Unexpected error: {str(e)}", + command=" ".join(cmd) if 'cmd' in locals() else "kubectl-diagrams" + ) + + + + diff --git a/webapp/backend/utils/validators.py b/webapp/backend/utils/validators.py index ee6e96e..f858adb 100644 --- a/webapp/backend/utils/validators.py +++ b/webapp/backend/utils/validators.py @@ -130,6 +130,24 @@ class InputValidator: return True, None + @staticmethod + def validate_k8s_name(name: str) -> bool: + """ + Validate a Kubernetes resource name. + + Args: + name: Resource name to validate + + Returns: + bool: True if valid + """ + if not name or not isinstance(name, str): + return False + # Kubernetes names must be lowercase alphanumeric, with hyphens allowed + # Must start and end with alphanumeric + pattern = re.compile(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + return bool(pattern.match(name)) and len(name) <= 253 + @staticmethod def looks_like_manifest(text: str) -> bool: """ diff --git a/webapp/frontend/src/components/common/Tabs.jsx b/webapp/frontend/src/components/common/Tabs.jsx index 953b834..2361480 100644 --- a/webapp/frontend/src/components/common/Tabs.jsx +++ b/webapp/frontend/src/components/common/Tabs.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import ManifestTab from '../tabs/ManifestTab/index.jsx'; import HelmTab from '../tabs/HelmTab/index.jsx'; import HelmFileTab from '../tabs/HelmFileTab/index.jsx'; +import ClusterTab from '../tabs/ClusterTab/index.jsx'; import InteractiveViewerTab from '../tabs/InteractiveViewerTab/index.jsx'; function Tabs({ historyContext }) { @@ -16,6 +17,7 @@ function Tabs({ historyContext }) { if (type === 'manifest') setActiveTab('manifest'); else if (type === 'helm') setActiveTab('helm'); else if (type === 'helmfile') setActiveTab('helmfile'); + else if (type === 'cluster') setActiveTab('cluster'); } }, [historyContext?.restoredItem]); @@ -23,6 +25,7 @@ function Tabs({ historyContext }) { { id: 'manifest', label: 'Manifest' }, { id: 'helm', label: 'Helm Chart' }, { id: 'helmfile', label: 'HelmFile' }, + { id: 'cluster', label: 'Cluster' }, { id: 'interactviewer', label: 'InteractiveViewer' }, ]; @@ -53,6 +56,7 @@ function Tabs({ historyContext }) { {activeTab === 'manifest' && } {activeTab === 'helm' && } {activeTab === 'helmfile' && } + {activeTab === 'cluster' && } {activeTab === 'interactviewer' && } diff --git a/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx b/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx new file mode 100644 index 0000000..e5ff687 --- /dev/null +++ b/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx @@ -0,0 +1,451 @@ +/** + * Cluster Input Component + * Handles cluster resource selection and diagram generation options + */ + +import { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Info, Server, RefreshCw, Layers } from 'lucide-react'; +import { toast } from 'sonner'; +import SubmitButton from '../../common/SubmitButton.jsx'; +import ManifestOptions from '../../options/ManifestOptions'; +import { getClusterNamespaces, getClusterResourceTypes } from '../../../services/diagramApi.js'; + +function ClusterInput({ + namespace, + setNamespace, + resourceTypes, + setResourceTypes, + allNamespaces, + setAllNamespaces, + outputFormat, + setOutputFormat, + extraArgs, + setExtraArgs, + withoutNamespace, + setWithoutNamespace, + errorMessage, + setErrorMessage, + isSubmitting, + onSubmit, +}) { + 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 ( +
+
+ +

Cluster Resources

+
+ +
+ {/* All Namespaces Checkbox */} +
+ handleAllNamespacesToggle(e.target.checked)} + className="w-4 h-4 rounded bg-gray-700 border-gray-600" + /> + +
+ + {/* Namespace Selection */} + {!allNamespaces && ( +
+
+ + +
+ + {namespaces.length === 0 && !loadingNamespaces && ( +

+ No namespaces found. Please ensure your Kubernetes cluster is running (e.g.,{' '} + minikube start) and click the + Refresh button. +

+ )} + {namespaces.length > 0 && ( +

+ Select the namespace to retrieve resources from. +

+ )} +
+ )} + + {/* Resource Types Selection */} +
+
+ +
+ + | + +
+
+ +
+ {loadingResourceTypes ? ( +
+ + + Loading resource types + {namespace + ? ` for namespace "${namespace}"` + : allNamespaces + ? ' from all namespaces' + : ''} + ... + +
+ ) : availableResourceTypes.length === 0 ? ( +
+

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

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

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

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

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

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

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

+
+
+ + + + + {isSubmitting ? 'Generating…' : 'Generate Cluster Diagram'} + + + {/* Help Message */} + {!isSubmitting && ( +
+

+ + + {namespaces.length === 0 ? ( + <> + Cluster not connected. Please start your Kubernetes cluster: +
- For minikube:{' '} + minikube start +
Then click the Refresh button above. See{' '} + TROUBLESHOOTING_CLUSTER.md for more help. + + ) : ( + <> + This will retrieve resources from your Kubernetes cluster using kubectl and + generate a diagram. Make sure you have proper cluster access configured via + kubeconfig. + + )} +
+

+
+ )} +
+ ); +} + +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, + setExtraArgs: PropTypes.func.isRequired, + withoutNamespace: PropTypes.bool.isRequired, + setWithoutNamespace: PropTypes.func.isRequired, + errorMessage: PropTypes.string, + setErrorMessage: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + onSubmit: PropTypes.func.isRequired, +}; + +export default ClusterInput; diff --git a/webapp/frontend/src/components/tabs/ClusterTab/ClusterOutput.jsx b/webapp/frontend/src/components/tabs/ClusterTab/ClusterOutput.jsx new file mode 100644 index 0000000..1486598 --- /dev/null +++ b/webapp/frontend/src/components/tabs/ClusterTab/ClusterOutput.jsx @@ -0,0 +1,58 @@ +/** + * Cluster Output Component + * Displays the generated cluster diagram and command execution details + */ + +import { motion } from 'motion/react'; +import NotationOptions from '../../options/NotationOptions'; +import DiagramViewer from '../../common/DiagramViewer.jsx'; +import ErrorAlert from '../../common/ErrorAlert.jsx'; +import DownloadButton from '../../common/DownloadButton.jsx'; + +function ClusterOutput({ + errorMessage, + diagram, + mimeType, + filename, + outputFormat, + viewerKey, + viewerRef, + onViewerLoad, + isSubmitting, +}) { + return ( +
+

Cluster Diagram

+ + {/* Error Alert */} + + + {/* Diagram Display */} + + + + + {/* Download Button */} + + + {/* Notation Options */} + {diagram && } +
+ ); +} + +export default ClusterOutput; diff --git a/webapp/frontend/src/components/tabs/ClusterTab/index.jsx b/webapp/frontend/src/components/tabs/ClusterTab/index.jsx new file mode 100644 index 0000000..5abd763 --- /dev/null +++ b/webapp/frontend/src/components/tabs/ClusterTab/index.jsx @@ -0,0 +1,188 @@ +/** + * Cluster Tab Container + * Main component that orchestrates live cluster diagram generation + */ +import { useState, useEffect, useRef, useCallback } from 'react'; +import PropTypes from 'prop-types'; +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 ClusterInput from './ClusterInput.jsx'; +import ClusterOutput from './ClusterOutput.jsx'; +import CommandDetails from '../../common/CommandDetails.jsx'; + +function ClusterTab({ historyContext }) { + // Input states + const [namespace, setNamespace] = useState(''); + const [resourceTypes, setResourceTypes] = useState([]); + const [allNamespaces, setAllNamespaces] = useState(false); + const [outputFormat, setOutputFormat] = useState(DEFAULTS.OUTPUT_FORMAT); + const [extraArgs, setExtraArgs] = useState(''); + const [withoutNamespace, setWithoutNamespace] = useState(false); + + // Diagram generation hook + const { + diagram, + command, + message, + mimeType, + filename, + stdout, + stderr, + errorMessage, + setErrorMessage, + isSubmitting, + viewerKey, + progressStep, + handleSubmit: generateDiagram, + resetOutput, + } = useDiagramGeneration({ + apiFunction: generateClusterDiagram, + validateInput: (params) => { + // Validation: either a namespace or allNamespaces must be selected + if (!params.allNamespaces && !params.namespace) { + return 'Please select a namespace or check "All Namespaces".'; + } + return null; + }, + diagramType: 'cluster', + }); + + // Track previous outputFormat to detect changes + const prevOutputFormatRef = useRef(outputFormat); + + // Reset output when output format changes (not on initial render) + useEffect(() => { + if (prevOutputFormatRef.current !== outputFormat && diagram) { + resetOutput(); + } + prevOutputFormatRef.current = outputFormat; + }, [outputFormat, diagram, resetOutput]); + + // Viewer synchronization hook for DOT_JSON format + const { viewerRef, handleViewerLoad } = useViewerSync({ + diagram, + outputFormat, + }); + + // Auto-scroll to output when diagram is ready + + // History restoration + const hasRestoredRef = useRef(false); + useEffect(() => { + if (historyContext?.restoredItem && !hasRestoredRef.current) { + const item = historyContext.restoredItem; + if (item.type === 'cluster') { + setNamespace(item.input?.namespace || ''); + setResourceTypes(item.input?.resourceTypes || []); + setAllNamespaces(item.input?.allNamespaces || false); + setOutputFormat(item.outputFormat || DEFAULTS.OUTPUT_FORMAT); + setExtraArgs(item.extraArgs || ''); + setWithoutNamespace(item.withoutNamespace || false); + hasRestoredRef.current = true; + } + } + }, [historyContext?.restoredItem]); + + // History tracking + useEffect(() => { + if (diagram && historyContext?.addToHistory) { + historyContext.addToHistory({ + type: 'cluster', + input: { namespace, resourceTypes, allNamespaces }, + outputFormat, + extraArgs, + withoutNamespace, + result: { + diagram, + mimeType, + filename, + message, + command, + stdout, + stderr, + }, + timestamp: Date.now(), + }); + } + }, [diagram]); + + // Handle diagram generation with proper parameters + const handleGenerate = useCallback(() => { + generateDiagram({ + namespace, + resourceTypes, + allNamespaces, + outputFormat, + extraArgs, + withoutNamespace, + }); + }, [ + generateDiagram, + namespace, + resourceTypes, + allNamespaces, + outputFormat, + extraArgs, + withoutNamespace, + ]); + + return ( +
+ {/* Input and Output Section */} +
+ {/* Input Section */} +
+ +
+ + {/* Output Section */} +
+ +
+
+ + {/* Command Details Section - Full width below */} + {(command || stdout || stderr || message) && ( + + )} +
+ ); +} + +ClusterTab.propTypes = { + historyContext: PropTypes.shape({ + restoredItem: PropTypes.object, + addToHistory: PropTypes.func, + }), +}; + +export default ClusterTab; diff --git a/webapp/frontend/src/services/diagramApi.js b/webapp/frontend/src/services/diagramApi.js index 7bd88c2..2de36b5 100644 --- a/webapp/frontend/src/services/diagramApi.js +++ b/webapp/frontend/src/services/diagramApi.js @@ -149,6 +149,169 @@ export async function generateHelmfileDiagram({ return response; } +/** + * Generate diagram from live Kubernetes cluster + * @param {Object} params - Request parameters + * @param {string} [params.namespace] - Namespace to diagram (optional) + * @param {Array} [params.resourceTypes] - Resource types to include + * @param {boolean} [params.allNamespaces] - Include all namespaces + * @param {string} params.outputFormat - Output format (png, jpg, svg, pdf, dot, dot_json) + * @param {string} [params.extraArgs] - Additional CLI arguments + * @param {boolean} [params.withoutNamespace] - Generate without namespace + * @returns {Promise} Response with diagram data + */ +export async function generateClusterDiagram({ + namespace = '', + resourceTypes = [], + allNamespaces = false, + outputFormat, + extraArgs = '', + withoutNamespace = false, +}) { + logger.debug('Requesting Cluster diagram generation', { + namespace, + allNamespaces, + outputFormat, + withoutNamespace, + }); + + const response = await apiFetch(API_ENDPOINTS.GENERATE_CLUSTER, { + body: JSON.stringify({ + namespace, + resourceTypes, + allNamespaces, + outputFormat, + extraArgs, + withoutNamespace, + }), + }); + + if (response.ok && response.data.diagram) { + logger.info('Cluster diagram generated successfully', { + namespace, + allNamespaces, + outputFormat, + withoutNamespace, + filename: response.data.filename, + }); + } + + return response; +} + +/** + * Get list of namespaces from Kubernetes cluster + * @returns {Promise} Response with namespaces list + */ +export async function getClusterNamespaces() { + logger.debug('Fetching cluster namespaces'); + + try { + const response = await fetch(API_ENDPOINTS.CLUSTER_NAMESPACES, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + logger.error('Failed to fetch namespaces', { + statusCode: response.status, + error: data?.error, + }); + } else { + logger.info('Namespaces fetched successfully', { + count: data?.namespaces?.length || 0, + }); + } + + return { + ok: response.ok, + status: response.status, + data, + }; + } catch (error) { + logger.error('Network error fetching namespaces', { error }); + throw error; + } +} + +/** + * Get list of resource types from Kubernetes cluster + * @param {string} [namespace] - Optional namespace filter + * @returns {Promise} Response with resource types list + */ +export async function getClusterResourceTypes(namespace = '') { + logger.debug('Fetching cluster resource types', { namespace }); + + try { + const url = namespace + ? `${API_ENDPOINTS.CLUSTER_RESOURCE_TYPES}?namespace=${encodeURIComponent(namespace)}` + : API_ENDPOINTS.CLUSTER_RESOURCE_TYPES; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + logger.error('Failed to fetch resource types', { + statusCode: response.status, + error: data?.error, + }); + } else { + logger.info('Resource types fetched successfully', { + count: data?.resourceTypes?.length || 0, + }); + } + + return { + ok: response.ok, + status: response.status, + data, + }; + } catch (error) { + logger.error('Network error fetching resource types', { error }); + throw error; + } +} + +/** + * 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 * @param {Object} params - Feedback parameters @@ -198,6 +361,10 @@ export default { generateManifestDiagram, generateHelmDiagram, generateHelmfileDiagram, + generateClusterDiagram, + getClusterNamespaces, + getClusterResourceTypes, + getClusterResources, submitFeedback, hasFatalErrors, }; diff --git a/webapp/frontend/src/utils/constants.js b/webapp/frontend/src/utils/constants.js index 8201deb..f015309 100644 --- a/webapp/frontend/src/utils/constants.js +++ b/webapp/frontend/src/utils/constants.js @@ -27,6 +27,10 @@ export const API_ENDPOINTS = Object.freeze({ GENERATE_MANIFEST: '/api/generate-diagram', GENERATE_HELM: '/api/generate-helm-diagram', GENERATE_HELMFILE: '/api/generate-helmfile-diagram', + GENERATE_CLUSTER: '/api/cluster/generate', + 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', }); From 55df49f6ab660c86f0b7125cec70dc4e533c8780 Mon Sep 17 00:00:00 2001 From: Sadallah Date: Wed, 10 Jun 2026 23:51:08 +0200 Subject: [PATCH 2/4] [Chore] Translate French comments and UI strings to English --- webapp/backend/routes/submit.py | 4 +- webapp/backend/services/helmService.py | 7 ++- webapp/backend/services/utils.py | 45 ++++++++++--------- webapp/backend/utils/validators.py | 4 +- webapp/frontend/src/App.jsx | 2 +- .../src/components/common/HistoryPanel.jsx | 4 +- .../components/common/PanZoomContainer.jsx | 20 ++++----- .../src/components/common/ProgressBar.jsx | 6 +-- .../frontend/src/components/common/Tabs.jsx | 4 +- .../src/components/tabs/HelmFileTab/index.jsx | 2 +- .../src/components/tabs/HelmTab/index.jsx | 4 +- .../src/components/tabs/ManifestTab/index.jsx | 2 +- webapp/frontend/src/utils/toast.js | 6 +-- 13 files changed, 56 insertions(+), 54 deletions(-) diff --git a/webapp/backend/routes/submit.py b/webapp/backend/routes/submit.py index c8144b4..01b8efb 100644 --- a/webapp/backend/routes/submit.py +++ b/webapp/backend/routes/submit.py @@ -23,13 +23,13 @@ def submit_feedback(): if note or comment: try: with open(Config.FEEDBACK_FILE, "a", encoding="utf-8") as f: - f.write(f"[{diagram_type.upper()}]\nNote: {note}\nCommentaire: {comment}\n\n") + f.write(f"[{diagram_type.upper()}]\nNote: {note}\nComment: {comment}\n\n") return ResponseBuilder.success( message="Feedback submitted successfully. Thank you!" ) except Exception as e: - # Log l'erreur dans le CSV + # Log error to CSV csv_logger = logging.getLogger(Config.LOGGER_NAME) csv_logger.error(f"Error writing feedback: {e}") return ResponseBuilder.error( diff --git a/webapp/backend/services/helmService.py b/webapp/backend/services/helmService.py index 26c65a7..ca1f2e7 100644 --- a/webapp/backend/services/helmService.py +++ b/webapp/backend/services/helmService.py @@ -25,11 +25,11 @@ def generate_from_helm( Returns: DiagramResult: Result of the generation """ - # Extraction du nom de base + # Extract base name for output file parsed = urlparse(chart_url) base_name = os.path.basename(parsed.path).replace(".tgz", "").replace(".tar.gz", "") - # Pour les URLs OCI + # OCI URLs use the last path segment as chart name if chart_url.startswith('oci://'): base_name = chart_url.rstrip('/').split('/')[-1] @@ -42,12 +42,11 @@ def generate_from_helm( if extra_args.strip(): cmd.extend(parse_extra_args(extra_args)) - # Execution + # Run the command and capture output proc = subprocess.run(cmd, check=False, capture_output=True, text=True) stdout_output = proc.stdout or "" stderr_output = proc.stderr or "" - # First we verify if there was an error before file exist has_error = proc.returncode != 0 or has_fatal_error(stdout_output, stderr_output) # Second we verify if there was an error in the stderr output diff --git a/webapp/backend/services/utils.py b/webapp/backend/services/utils.py index 4bce85e..e072f53 100644 --- a/webapp/backend/services/utils.py +++ b/webapp/backend/services/utils.py @@ -1,4 +1,4 @@ -"""Utilitaires pour les services de génération de diagrammes.""" +"""Utilities for diagram generation services.""" import base64 import shlex @@ -6,30 +6,30 @@ from constants import TEXT_FORMATS def has_fatal_error(stdout_txt: str, stderr_txt: str) -> bool: """ - Vérifie si la sortie contient une erreur fatale. - + Check whether subprocess output contains a fatal error marker. + Args: - stdout_txt: Sortie standard - stderr_txt: Sortie d'erreur - + stdout_txt: Standard output text + stderr_txt: Standard error text + Returns: - bool: True si erreur fatale détectée + bool: True if a fatal error was detected """ return ("error:" in (stdout_txt or "").lower()) or ("error:" in (stderr_txt or "").lower()) def parse_extra_args(extra_args: str) -> list[str]: """ - Parse les arguments supplémentaires. - + Parse a string of extra CLI arguments using shell-like tokenization. + Args: - extra_args: Arguments supplémentaires en string - + extra_args: Space-separated argument string (may include quoted tokens) + Returns: - list[str]: Liste des arguments parsés - + list[str]: Parsed argument tokens + Raises: - ValueError: Si les arguments sont invalides + ValueError: If the argument string has invalid shell syntax """ if not extra_args or not extra_args.strip(): return [] @@ -41,15 +41,18 @@ def parse_extra_args(extra_args: str) -> list[str]: def encode_content(content: bytes, output_format: str) -> str: """ - Encode le contenu en base64 ou UTF-8 selon le format. - + Encode diagram content for JSON transport. + + Text-based formats (SVG, DOT, DOT_JSON, DRAWIO) are decoded as UTF-8. + Binary formats (PNG, JPG, PDF) are base64-encoded. + Args: - content: Contenu à encoder - output_format: Format de sortie - + content: Raw file content + output_format: Output format string (e.g. 'png', 'svg') + Returns: - str: Contenu encodé + str: Encoded content ready for the API response """ if output_format in TEXT_FORMATS: return content.decode("utf-8") - return base64.b64encode(content).decode("utf-8") + return base64.b64encode(content).decode("utf-8") \ No newline at end of file diff --git a/webapp/backend/utils/validators.py b/webapp/backend/utils/validators.py index f858adb..f35745d 100644 --- a/webapp/backend/utils/validators.py +++ b/webapp/backend/utils/validators.py @@ -75,7 +75,7 @@ class InputValidator: url: URL of the Helm chart Returns: - Tuple[bool, Optional[str]]: (est_valide, message_erreur) + Tuple[bool, Optional[str]]: (is_valid, error_message) """ if not url or not url.strip(): return False, "Chart URL cannot be empty." @@ -121,7 +121,7 @@ class InputValidator: Tuple[bool, Optional[str]]: (is_valid, error_message) """ if not args or not args.strip(): - return True, None # Les args vides sont valides + return True, None dangerous_chars = [';', '&', '|', '`', '$', '(', ')'] for char in dangerous_chars: diff --git a/webapp/frontend/src/App.jsx b/webapp/frontend/src/App.jsx index e0ca8a9..cf5aa11 100644 --- a/webapp/frontend/src/App.jsx +++ b/webapp/frontend/src/App.jsx @@ -21,7 +21,7 @@ function App() { setRestoredItem(null); }; - // Mémoriser historyContext pour éviter de le recréer à chaque render + // Memoize historyContext to avoid recreating it on every render const historyContext = useMemo( () => ({ addToHistory, getHistoryItem, restoredItem, clearRestoredItem }), [addToHistory, getHistoryItem, restoredItem] diff --git a/webapp/frontend/src/components/common/HistoryPanel.jsx b/webapp/frontend/src/components/common/HistoryPanel.jsx index 7424f2c..5148ab3 100644 --- a/webapp/frontend/src/components/common/HistoryPanel.jsx +++ b/webapp/frontend/src/components/common/HistoryPanel.jsx @@ -120,9 +120,9 @@ const HistoryPanel = ({ history, onRestore, onRemove, onClear, isOpen, onToggle {history.length === 0 ? (
-

Aucun diagramme dans l'historique

+

No diagrams in history

- Les diagrammes générés apparaîtront ici + Generated diagrams will appear here

) : ( diff --git a/webapp/frontend/src/components/common/PanZoomContainer.jsx b/webapp/frontend/src/components/common/PanZoomContainer.jsx index 7dcb97c..b460124 100644 --- a/webapp/frontend/src/components/common/PanZoomContainer.jsx +++ b/webapp/frontend/src/components/common/PanZoomContainer.jsx @@ -14,7 +14,7 @@ export default function PanZoomContainer({ scrollPanSpeed = 3.0, buttonZoomFactor = 1.25, buttonZoomFactorFast = 1.5, - // Souris + // Mouse mouseZoomDampen = 0.018, clampDeltaY = 50, }) { @@ -22,17 +22,17 @@ export default function PanZoomContainer({ const measureRef = useRef(null); const isInControls = (el) => !!(el && el.closest && el.closest('.kd-controls')); - // États affichés + // Displayed state (reactive) const [scale, _setScale] = useState(1); const [tx, _setTx] = useState(0); const [ty, _setTy] = useState(0); - // Réfs calculs + animation + // Refs for calculation and animation (non-reactive) const scaleRef = useRef(1); const txRef = useRef(0); const tyRef = useRef(0); - // Cibles animées + // Animated targets const targetScaleRef = useRef(1); const targetTxRef = useRef(0); const targetTyRef = useRef(0); @@ -55,7 +55,7 @@ export default function PanZoomContainer({ const [naturalSize, setNaturalSize] = useState({ w: 0, h: 0 }); const [vpSize, setVpSize] = useState({ w: 0, h: 0 }); - // Mesures + // Measure natural content size and viewport size useLayoutEffect(() => { const el = measureRef.current; if (!el) return; @@ -83,7 +83,7 @@ export default function PanZoomContainer({ }; }, []); - // Fit initial + // Fit content into viewport on first render useEffect(() => { if (!naturalSize.w || !naturalSize.h || !vpSize.w || !vpSize.h) return; fit(true); @@ -91,7 +91,7 @@ export default function PanZoomContainer({ const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); - // Anime position/zoom vers la cible + // Animate position/zoom toward targets with smoothing const animate = useCallback(() => { const s = scaleRef.current; const tx = txRef.current; @@ -167,7 +167,7 @@ export default function PanZoomContainer({ dY *= vp.clientHeight; } - // --- ZOOM (pinch/ctrl) : adoucir la souris, garder le trackpad tel quel + // --- ZOOM (pinch/ctrl): dampen mouse wheel, keep trackpad as-is if (e.ctrlKey) { const isTrackpad = isTrackpadWheel(e); const sens = isTrackpad ? pinchSensitivity : pinchSensitivity * mouseZoomDampen; @@ -178,7 +178,7 @@ export default function PanZoomContainer({ return; } - // --- Mode "zoom" à la molette : même adoucissement pour souris + // --- Wheel zoom mode: same dampening as pinch/ctrl if (wheelMode === 'zoom') { const isTrackpad = isTrackpadWheel(e); const sens = isTrackpad ? wheelSensitivity : wheelSensitivity * mouseZoomDampen; @@ -189,7 +189,7 @@ export default function PanZoomContainer({ return; } - // --- Par défaut : PAN au wheel/trackpad (deux doigts) + // --- Default: pan with wheel/trackpad (two-finger scroll) const k = scrollPanSpeed; targetTxRef.current = txRef.current - dX * k; targetTyRef.current = tyRef.current - dY * k; diff --git a/webapp/frontend/src/components/common/ProgressBar.jsx b/webapp/frontend/src/components/common/ProgressBar.jsx index c94ab0f..802e948 100644 --- a/webapp/frontend/src/components/common/ProgressBar.jsx +++ b/webapp/frontend/src/components/common/ProgressBar.jsx @@ -9,8 +9,8 @@ const ProgressBar = ({ currentStep = 'idle', isVisible = false }) => { const steps = [ { id: 'parsing', label: 'Parsing' }, { id: 'validation', label: 'Validation' }, - { id: 'generation', label: 'Génération' }, - { id: 'rendering', label: 'Rendu' }, + { id: 'generation', label: 'Generation' }, + { id: 'rendering', label: 'Rendering' }, ]; const getStepStatus = (stepId) => { @@ -65,7 +65,7 @@ const ProgressBar = ({ currentStep = 'idle', isVisible = false }) => { >

- {currentStep === 'completed' ? '✓ Génération terminée' : 'Génération en cours...'} + {currentStep === 'completed' ? '✓ Generation complete' : 'Generation in progress...'}

{currentStep !== 'completed' && currentStep !== 'error' && ( diff --git a/webapp/frontend/src/components/common/Tabs.jsx b/webapp/frontend/src/components/common/Tabs.jsx index 2361480..73b4afe 100644 --- a/webapp/frontend/src/components/common/Tabs.jsx +++ b/webapp/frontend/src/components/common/Tabs.jsx @@ -31,7 +31,7 @@ function Tabs({ historyContext }) { return (
- {/* Onglets */} + {/* Tabs */}
{tabs.map((tab) => ( - {/* Contenu des tabs */} + {/* Tab content */}
{activeTab === 'manifest' && } {activeTab === 'helm' && } diff --git a/webapp/frontend/src/components/tabs/HelmFileTab/index.jsx b/webapp/frontend/src/components/tabs/HelmFileTab/index.jsx index 08d06f9..671d8ff 100644 --- a/webapp/frontend/src/components/tabs/HelmFileTab/index.jsx +++ b/webapp/frontend/src/components/tabs/HelmFileTab/index.jsx @@ -98,7 +98,7 @@ function HelmFileTab({ historyContext }) { historyContext.addToHistory(historyItem); lastHistoryIdRef.current = historyId; } - }, [diagram, progressStep]); // Dépendances minimales pour éviter la boucle + }, [diagram, progressStep]); // minimal deps — avoids a save loop // Restore from history useEffect(() => { diff --git a/webapp/frontend/src/components/tabs/HelmTab/index.jsx b/webapp/frontend/src/components/tabs/HelmTab/index.jsx index be5cc11..d194f78 100644 --- a/webapp/frontend/src/components/tabs/HelmTab/index.jsx +++ b/webapp/frontend/src/components/tabs/HelmTab/index.jsx @@ -93,7 +93,7 @@ function HelmTab({ historyContext }) { historyContext.addToHistory(historyItem); lastHistoryIdRef.current = historyId; } - }, [diagram, progressStep]); // Dépendances minimales pour éviter la boucle + }, [diagram, progressStep]); // minimal deps — avoids a save loop // Restore from history useEffect(() => { @@ -133,7 +133,7 @@ function HelmTab({ historyContext }) { setChartUrl(v); setBackendError(''); - // Valider seulement si l'URL n'est pas vide + // Validate only if URL is not empty if (v && v.trim()) { setInputError( isValidChartUrl(v) diff --git a/webapp/frontend/src/components/tabs/ManifestTab/index.jsx b/webapp/frontend/src/components/tabs/ManifestTab/index.jsx index 029909f..6e3fd48 100644 --- a/webapp/frontend/src/components/tabs/ManifestTab/index.jsx +++ b/webapp/frontend/src/components/tabs/ManifestTab/index.jsx @@ -101,7 +101,7 @@ function ManifestTab({ historyContext }) { historyContext.addToHistory(historyItem); lastHistoryIdRef.current = historyId; } - }, [diagram, progressStep]); // Dépendances minimales pour éviter la boucle + }, [diagram, progressStep]); // minimal deps — avoids a save loop // Restore from history useEffect(() => { diff --git a/webapp/frontend/src/utils/toast.js b/webapp/frontend/src/utils/toast.js index 43af523..81aece7 100644 --- a/webapp/frontend/src/utils/toast.js +++ b/webapp/frontend/src/utils/toast.js @@ -77,9 +77,9 @@ export const dismissToast = (toastId) => { */ export const showPromise = (promise, messages) => { return toast.promise(promise, { - loading: messages.loading || 'Chargement...', - success: messages.success || 'Terminé !', - error: messages.error || 'Une erreur est survenue', + loading: messages.loading || 'Loading...', + success: messages.success || 'Done!', + error: messages.error || 'An error occurred', }); }; From 7e105eb79ad1cddbdffbbc267ecec10f123f6989 Mon Sep 17 00:00:00 2001 From: Sadallah Date: Wed, 10 Jun 2026 23:58:18 +0200 Subject: [PATCH 3/4] [Feature] Refactor cluster tab with improved kubectl handling and stateless UI --- webapp/backend/app.py | 2 +- webapp/backend/routes/cluster.py | 26 +- webapp/backend/services/__init__.py | 3 + webapp/backend/services/clusterService.py | 349 ++++++-------- .../tabs/ClusterTab/ClusterInput.jsx | 456 ++++++++---------- .../src/components/tabs/ClusterTab/index.jsx | 59 ++- webapp/frontend/src/hooks/useClusterData.js | 229 +++++++++ webapp/frontend/src/services/diagramApi.js | 73 ++- webapp/frontend/src/utils/constants.js | 2 +- 9 files changed, 699 insertions(+), 500 deletions(-) create mode 100644 webapp/frontend/src/hooks/useClusterData.js 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', }); From 700d2499866140bc13d4ea4d5eb87223e1758176 Mon Sep 17 00:00:00 2001 From: Sadallah Date: Thu, 11 Jun 2026 11:55:22 +0200 Subject: [PATCH 4/4] [Feature] Add Docker kubectl support and cluster connectivity + Apply Prettier formatting to frontend files --- webapp/backend/Dockerfile | 7 +++++ webapp/docker-compose.yml | 7 +++++ .../src/components/common/HistoryPanel.jsx | 4 +-- .../tabs/ClusterTab/ClusterInput.jsx | 26 ++++++++++++------- webapp/frontend/src/hooks/useClusterData.js | 5 ++-- webapp/frontend/src/services/diagramApi.js | 1 - 6 files changed, 33 insertions(+), 17 deletions(-) diff --git a/webapp/backend/Dockerfile b/webapp/backend/Dockerfile index 82d3e0a..5369ef0 100644 --- a/webapp/backend/Dockerfile +++ b/webapp/backend/Dockerfile @@ -23,6 +23,13 @@ RUN apk update && apk add --no-cache \ musl-dev \ && rm -rf /var/cache/apk/* +# Install kubectl (statically compiled Go binary — works on Alpine/musl) +RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') && \ + KUBECTL_VERSION=$(curl -fsSL https://dl.k8s.io/release/stable.txt) && \ + curl -fsSL -o /usr/local/bin/kubectl \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" && \ + chmod +x /usr/local/bin/kubectl + # Copy Helm from first stage COPY --from=helm /usr/bin/helm /usr/local/bin/helm diff --git a/webapp/docker-compose.yml b/webapp/docker-compose.yml index e05429d..8e29b07 100644 --- a/webapp/docker-compose.yml +++ b/webapp/docker-compose.yml @@ -43,8 +43,13 @@ services: volumes: # Persist logs outside container - ./backend/logs:/app/logs + # Mount host kubeconfig so kubectl can reach the cluster started on the host + - ${HOME}/.kube:/root/.kube:ro + # Mount minikube certs at the same absolute path as on the host (kubeconfig references them by absolute path) + - ${HOME}/.minikube:${HOME}/.minikube:ro networks: - kubediagrams-network + - minikube restart: unless-stopped healthcheck: test: ["CMD", "curl", "--fail", "--silent", "http://localhost:5000/api/health"] @@ -59,3 +64,5 @@ services: networks: kubediagrams-network: driver: bridge + minikube: + external: true diff --git a/webapp/frontend/src/components/common/HistoryPanel.jsx b/webapp/frontend/src/components/common/HistoryPanel.jsx index 5148ab3..e3810f7 100644 --- a/webapp/frontend/src/components/common/HistoryPanel.jsx +++ b/webapp/frontend/src/components/common/HistoryPanel.jsx @@ -121,9 +121,7 @@ const HistoryPanel = ({ history, onRestore, onRemove, onClear, isOpen, onToggle

No diagrams in history

-

- Generated diagrams will appear here -

+

Generated diagrams will appear here

) : ( history.map((item) => ( diff --git a/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx b/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx index 730c746..399d7ab 100644 --- a/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx +++ b/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx @@ -80,7 +80,10 @@ function ClusterInput({