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"
+ />
+
+ All Namespaces
+
+
+
+ {/* Namespace Selection */}
+ {!allNamespaces && (
+
+
+ Namespace
+ {
+ fetchNamespaces();
+ fetchContext();
+ }}
+ disabled={loadingNamespaces}
+ className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
+ >
+
+ Refresh
+
+
+
handleNamespaceChange(e.target.value)}
+ disabled={loadingNamespaces || namespaces.length === 0}
+ >
+
+ {namespaces.length === 0
+ ? 'No namespaces available - check cluster connection'
+ : 'Select a namespace'}
+
+ {namespaces.map((ns) => (
+
+ {ns}
+
+ ))}
+
+ {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 */}
+
+
+
+
+
+ Resource Types
+
+
+
+ Refresh
+
+
+
+
+ Select Common
+
+ |
+
+ Select All
+
+ |
+
+ Deselect All
+
+
+
+
+ {/* 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.
+
+
+
+ Retry
+
+
+ ) : (
+ <>
+ {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 (
+
+ onToggle(rt.name)}
+ className="w-4 h-4 rounded bg-gray-700 border-gray-600 shrink-0"
+ />
+
+
+ {rt.name}
+ {rt.shortNames?.length > 0 && (
+ ({rt.shortNames.join(', ')})
+ )}
+
+ {!rt.namespaced && (
+
+ cluster
+
+ )}
+
+
+ );
+}
+
+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',
});
};