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/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/backend/app.py b/webapp/backend/app.py index 09d154c..e1a87be 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 @@ -60,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.""" @@ -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..74cda4b --- /dev/null +++ b/webapp/backend/routes/cluster.py @@ -0,0 +1,122 @@ +"""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, + 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({ + "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(): + """Return all resource types known by the cluster, tagged with namespace scope and common status.""" + try: + resource_types = get_resource_types() + 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)};" + 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 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") + + # 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, + 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/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/__init__.py b/webapp/backend/services/__init__.py index 0003b65..86df585 100644 --- a/webapp/backend/services/__init__.py +++ b/webapp/backend/services/__init__.py @@ -4,11 +4,22 @@ 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, + get_current_context, +) __all__ = [ 'DiagramResult', 'FileManager', 'generate_from_manifest', 'generate_from_helm', - 'generate_from_helmfile' + 'generate_from_helmfile', + '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 new file mode 100644 index 0000000..c73662a --- /dev/null +++ b/webapp/backend/services/clusterService.py @@ -0,0 +1,257 @@ +"""Service for generating diagrams from live Kubernetes cluster resources.""" +import subprocess +import json +import os +import uuid +import tempfile +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 + +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: + proc = _run_kubectl(["kubectl", "get", "namespaces", "-o", "json"], timeout=20) + result = json.loads(proc.stdout) + 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) + _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"Could not parse kubectl output: {str(e)}") + except Exception as e: + raise RuntimeError(f"Unexpected error while fetching namespaces: {str(e)}") + + +def get_resource_types() -> List[Dict[str, Any]]: + """ + 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: + proc = _run_kubectl( + ["kubectl", "api-resources", "--verbs=list", "--no-headers"], timeout=30 + ) + resources = [] + seen = set() + + for line in proc.stdout.strip().splitlines(): + tokens = line.split() + if len(tokens) < 4: + continue + + 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) + + # 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 [] + + resources.append({ + "name": simple_name, + "shortNames": short_names, + "namespaced": namespaced, + "isCommon": simple_name in COMMON_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) + _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 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, + ) + + +def generate_from_cluster( + resource_types: List[str], + namespace: Optional[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.""" + cmd: List[str] = [] + try: + resources_arg = ','.join(resource_types) + base_name = f"cluster-diagram-{uuid.uuid4().hex[:8]}" + base_path = os.path.join(tempfile.gettempdir(), base_name) + requested_output, png_output = FileManager.get_output_paths(base_path, output_format) + + cmd = ["kubectl-diagrams", resources_arg] + + if all_namespaces: + cmd.append("--all-namespaces") + elif namespace: + cmd.extend(["-n", namespace]) + + cmd.extend(["-o", requested_output]) + + if output_format != "png": + cmd.extend(["-f", output_format]) + + if without_namespace: + cmd.append("--without-namespace") + + if extra_args.strip(): + cmd.extend(parse_extra_args(extra_args)) + + proc = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60) + stdout_output = proc.stdout or "" + stderr_output = proc.stderr or "" + + if proc.returncode != 0 or has_fatal_error(stdout_output, 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=f"Output file not found: {requested_output}", + command=" ".join(cmd), + stdout=stdout_output, + stderr=stderr_output + ) + output_file, produced_format = output_info + + content = FileManager.read_file_content(output_file, binary=True) + encoded = encode_content(content, produced_format) + FileManager.cleanup_files(output_file) + + 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 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) or "kubectl-diagrams" + ) + except Exception as e: + return DiagramResult( + success=False, + error=f"Unexpected error: {str(e)}", + command=" ".join(cmd) or "kubectl-diagrams" + ) \ No newline at end of file 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 ee6e96e..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: @@ -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/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/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..e3810f7 100644 --- a/webapp/frontend/src/components/common/HistoryPanel.jsx +++ b/webapp/frontend/src/components/common/HistoryPanel.jsx @@ -120,10 +120,8 @@ const HistoryPanel = ({ history, onRestore, onRemove, onClear, isOpen, onToggle {history.length === 0 ? (
-

Aucun diagramme dans l'historique

-

- Les diagrammes générés apparaîtront ici -

+

No diagrams in history

+

Generated diagrams will appear here

) : ( history.map((item) => ( 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 953b834..73b4afe 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,12 +25,13 @@ function Tabs({ historyContext }) { { id: 'manifest', label: 'Manifest' }, { id: 'helm', label: 'Helm Chart' }, { id: 'helmfile', label: 'HelmFile' }, + { id: 'cluster', label: 'Cluster' }, { id: 'interactviewer', label: 'InteractiveViewer' }, ]; return (
- {/* Onglets */} + {/* Tabs */}
{tabs.map((tab) => ( - {/* Contenu des tabs */} + {/* Tab content */}
{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..399d7ab --- /dev/null +++ b/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx @@ -0,0 +1,415 @@ +/** + * Cluster Input Component + * Stateless presentational component for cluster resource selection and diagram options. + * All state and handlers are provided by ClusterTab/index.jsx via useClusterData. + */ + +import PropTypes from 'prop-types'; +import { Info, Server, RefreshCw, Layers, Search } from 'lucide-react'; +import SubmitButton from '../../common/SubmitButton.jsx'; +import ManifestOptions from '../../options/ManifestOptions'; + +function ClusterInput({ + // Input state (from ClusterTab/index.jsx) + namespace, + resourceTypes, + allNamespaces, + outputFormat, + setOutputFormat, + extraArgs, + setExtraArgs, + withoutNamespace, + setWithoutNamespace, + errorMessage, + 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, +}) { + return ( +
+
+ +

Cluster Resources

+
+ + {currentContext && ( +

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

+ )} + +
+ {/* 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 */} +
+
+
+ + +
+
+ + | + + | + +
+
+ + {/* Scope hint */} + {availableResourceTypes.length > 0 && ( +

+ {resourceTypeSearch + ? `${filteredResourceTypes.length} of ${availableResourceTypes.length} types` + : `${availableResourceTypes.length} types known by the cluster`} + {namespace && !allNamespaces && !resourceTypeSearch && ( + + {' '} + — resources tagged + cluster + {' '} + are not bound to a namespace but can still be included (e.g. nodes for pod + placement). + + )} +

+ )} + + {availableResourceTypes.length > 0 && ( +
+ + setResourceTypeSearch(e.target.value)} + placeholder="Search resource types…" + className="w-full pl-7 pr-3 py-1.5 text-xs rounded bg-gray-700 text-white placeholder-gray-500 border border-gray-600 focus:outline-none focus:border-blue-500" + /> +
+ )} + +
+ {loadingResourceTypes ? ( +
+ + Loading resource types from cluster… +
+ ) : filteredResourceTypes.length === 0 && resourceTypeSearch ? ( +
+

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

+
+ ) : availableResourceTypes.length === 0 ? ( +
+

+ No resource types available. Please check your cluster connection. +

+ +
+ ) : ( + <> + {commonVisible.length > 0 && ( +
+

+ Common Resources +

+
+ {commonVisible.map((rt) => ( + + ))} +
+
+ )} + {otherVisible.length > 0 && ( +
+

+ Other Resources +

+
+ {otherVisible.map((rt) => ( + + ))} +
+
+ )} + + )} +
+

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

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

+ + + {namespaces.length === 0 ? ( + <> + No cluster detected. Start your cluster, then click{' '} + Refresh: +
• minikube: minikube start +
• kind:{' '} + kind create cluster +
• k3d: k3d cluster create +
+ Make sure kubectl is installed + and your kubeconfig points to the right context. + + ) : ( + <> + Retrieves resources from your cluster via kubectl and generates a diagram. Make + sure kubectl is configured with the correct context ( + kubectl config current-context + ). + + )} +
+

+
+ )} +
+ ); +} + +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, + resourceTypes: PropTypes.arrayOf(PropTypes.string).isRequired, + allNamespaces: PropTypes.bool.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, + 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; 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..f25147d --- /dev/null +++ b/webapp/frontend/src/components/tabs/ClusterTab/index.jsx @@ -0,0 +1,231 @@ +/** + * 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 { useClusterData } from '../../../hooks/useClusterData.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, + 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 }); + + // 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 + + // 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/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/hooks/useClusterData.js b/webapp/frontend/src/hooks/useClusterData.js new file mode 100644 index 0000000..7a57563 --- /dev/null +++ b/webapp/frontend/src/hooks/useClusterData.js @@ -0,0 +1,228 @@ +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, + }; +} diff --git a/webapp/frontend/src/services/diagramApi.js b/webapp/frontend/src/services/diagramApi.js index 7bd88c2..4d9b7b0 100644 --- a/webapp/frontend/src/services/diagramApi.js +++ b/webapp/frontend/src/services/diagramApi.js @@ -149,6 +149,163 @@ 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 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 + */ +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 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() { + logger.debug('Fetching cluster resource types'); + + try { + const response = await fetch(API_ENDPOINTS.CLUSTER_RESOURCE_TYPES, { + 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; + } +} + /** * Submit user feedback * @param {Object} params - Feedback parameters @@ -198,6 +355,10 @@ export default { generateManifestDiagram, generateHelmDiagram, generateHelmfileDiagram, + generateClusterDiagram, + getClusterContext, + getClusterNamespaces, + getClusterResourceTypes, submitFeedback, hasFatalErrors, }; diff --git a/webapp/frontend/src/utils/constants.js b/webapp/frontend/src/utils/constants.js index 8201deb..374aa3c 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_CONTEXT: '/api/cluster/context', + CLUSTER_NAMESPACES: '/api/cluster/namespaces', + CLUSTER_RESOURCE_TYPES: '/api/cluster/resource-types', SUBMIT_FEEDBACK: '/api/submit-feedback', EXAMPLES: '/api/examples', }); 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', }); };