diff --git a/webapp/backend/app.py b/webapp/backend/app.py index e1a87be..a5f7cb4 100644 --- a/webapp/backend/app.py +++ b/webapp/backend/app.py @@ -8,6 +8,7 @@ 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 routes.render import render_bp from utils.access_logger import log_request, get_real_ip, get_all_ip_headers from time import time @@ -91,6 +92,7 @@ def create_app(): app.register_blueprint(helmfile_bp) app.register_blueprint(submit_bp) app.register_blueprint(cluster_bp) + app.register_blueprint(render_bp) return app diff --git a/webapp/backend/config.py b/webapp/backend/config.py index d9a8de2..2affb6b 100644 --- a/webapp/backend/config.py +++ b/webapp/backend/config.py @@ -8,7 +8,7 @@ class Config: """Config App and Logger.""" # Flask - DEBUG = True + DEBUG = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true' PORT = 5000 HOST = 'localhost' diff --git a/webapp/backend/constants.py b/webapp/backend/constants.py index 4e097a0..13342f1 100644 --- a/webapp/backend/constants.py +++ b/webapp/backend/constants.py @@ -11,10 +11,12 @@ MIME_TYPES = { "pdf": "application/pdf", "dot": "text/vnd.graphviz", "dot_json": "application/json", - "drawio": "application/xml" + "drawio": "application/xml", + "mermaid": "text/vnd.mermaid", + "d2": "text/vnd.d2" } # no binary format -TEXT_FORMATS = {"svg", "dot", "dot_json", "drawio"} +TEXT_FORMATS = {"svg", "dot", "dot_json", "drawio", "mermaid", "d2"} # Manifest_detector MANIFEST_RE = re.compile(r'^\s*apiVersion\s*:\s*.+$', re.MULTILINE) KIND_RE = re.compile(r'^\s*kind\s*:\s*.+$', re.MULTILINE) @@ -23,3 +25,23 @@ MAX_LOG_LENGTH = 999999 # file extensions YAML_EXTENSIONS = ['.yaml', '.yml'] TGZ_EXTENSIONS = ['.tgz', '.tar.gz'] + +# Allowlist of CLI flags accepted through the free-text "extra args" field, per +# underlying tool. -o/--output, -f/--format and -c/--config are excluded even +# though the tools support them: -o/-f are already managed by the app itself +# (allowing them would let a request override the computed output path/format), +# and -c/--config (plus helm's --values/--set-file) let the tool read an +# arbitrary local file path, which would be a local file disclosure primitive. +EXTRA_ARGS_ALLOWED_FLAGS = { + "kube-diagrams": { + "--embed-all-icons", "-v", "--verbose", "-n", "--namespace", "--without-namespace", + }, + "kubectl-diagrams": { + "--embed-all-icons", "--version", + }, + "helm-diagrams": { + "--set", "--set-string", "--set-json", "--set-literal", + "-g", "--generate-name", "--include-crds", "-l", "--labels", + "--name-template", "--version", "--embed-all-icons", + }, +} diff --git a/webapp/backend/routes/cluster.py b/webapp/backend/routes/cluster.py index 74cda4b..cfa3908 100644 --- a/webapp/backend/routes/cluster.py +++ b/webapp/backend/routes/cluster.py @@ -7,6 +7,7 @@ from services import ( get_namespaces, get_resource_types, get_current_context, + get_contexts, ) from utils import InputValidator, ResponseBuilder @@ -16,43 +17,48 @@ 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)}") + context, error = get_current_context() + if error: + return ResponseBuilder.error(error) + return ResponseBuilder.success({"context": context}) + + +@cluster_bp.route('/api/cluster/contexts', methods=['GET']) +def list_contexts(): + """Return the list of kubectl contexts configured locally, marking which one is current.""" + contexts, error = get_contexts() + if error: + return ResponseBuilder.error(error) + return ResponseBuilder.success({ + "contexts": contexts, + "count": len(contexts) + }) @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)}") + context = request.args.get('context') or None + namespaces, error = get_namespaces(context=context) + if error: + return ResponseBuilder.error(error) + return ResponseBuilder.success({ + "namespaces": namespaces, + "count": len(namespaces) + }) @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)}") + context = request.args.get('context') or None + resource_types, error = get_resource_types(context=context) + if error: + return ResponseBuilder.error(error) + return ResponseBuilder.success({ + "resourceTypes": resource_types, + "count": len(resource_types) + }) @cluster_bp.route('/api/cluster/generate', methods=['POST']) @@ -66,6 +72,7 @@ def generate_cluster_diagram(): output_format = (data.get('outputFormat') or 'png').lower() extra_args = data.get('extraArgs', '') without_namespace = data.get('withoutNamespace', False) + context = data.get('context') or None # Log to CSV client_ip = request.remote_addr @@ -76,7 +83,8 @@ def generate_cluster_diagram(): f"allNamespaces={all_namespaces};" f"format={output_format};" f"extraArgs={compact_for_log(extra_args)};" - f"withoutNamespace={without_namespace}" + f"withoutNamespace={without_namespace};" + f"context={context}" ) log_to_csv(client_ip, route, params) @@ -94,7 +102,7 @@ def generate_cluster_diagram(): return ResponseBuilder.validation_error("outputFormat", error_msg) # Extra arguments validation - is_valid, error_msg = InputValidator.validate_extra_args(extra_args) + is_valid, error_msg = InputValidator.validate_extra_args(extra_args, "kubectl-diagrams") if not is_valid: return ResponseBuilder.validation_error("extraArgs", error_msg) @@ -105,7 +113,8 @@ def generate_cluster_diagram(): all_namespaces=all_namespaces, output_format=output_format, extra_args=extra_args, - without_namespace=without_namespace + without_namespace=without_namespace, + context=context ) if result.success: diff --git a/webapp/backend/routes/helm.py b/webapp/backend/routes/helm.py index 2a4fb89..3848f3c 100644 --- a/webapp/backend/routes/helm.py +++ b/webapp/backend/routes/helm.py @@ -32,7 +32,7 @@ def generate_helm_diagram(): return ResponseBuilder.validation_error("outputFormat", error_msg) # Extra arguments validation - is_valid, error_msg = InputValidator.validate_extra_args(extra_args) + is_valid, error_msg = InputValidator.validate_extra_args(extra_args, "helm-diagrams") if not is_valid: return ResponseBuilder.validation_error("extraArgs", error_msg) diff --git a/webapp/backend/routes/helmfile.py b/webapp/backend/routes/helmfile.py index e1e6793..863b898 100644 --- a/webapp/backend/routes/helmfile.py +++ b/webapp/backend/routes/helmfile.py @@ -40,7 +40,7 @@ def generate_helmfile_diagram(): return ResponseBuilder.validation_error("outputFormat", error_msg) # Extra arguments validation - is_valid, error_msg = InputValidator.validate_extra_args(extra_args) + is_valid, error_msg = InputValidator.validate_extra_args(extra_args, "kube-diagrams") if not is_valid: return ResponseBuilder.validation_error("extraArgs", error_msg) diff --git a/webapp/backend/routes/manifest.py b/webapp/backend/routes/manifest.py index 3cb1310..b2b08aa 100644 --- a/webapp/backend/routes/manifest.py +++ b/webapp/backend/routes/manifest.py @@ -44,7 +44,7 @@ def generate_diagram(): return ResponseBuilder.validation_error("outputFormat", error_msg) # Extra arguments validation - is_valid, error_msg = InputValidator.validate_extra_args(extra_args) + is_valid, error_msg = InputValidator.validate_extra_args(extra_args, "kube-diagrams") if not is_valid: return ResponseBuilder.validation_error("extraArgs", error_msg) diff --git a/webapp/backend/routes/render.py b/webapp/backend/routes/render.py new file mode 100644 index 0000000..6ba68c9 --- /dev/null +++ b/webapp/backend/routes/render.py @@ -0,0 +1,30 @@ +"""Route for on-demand rendering of already-generated diagram source.""" +from flask import Blueprint, request + +from services import dot_to_svg +from utils import ResponseBuilder +from .utils import compact_for_log, log_to_csv + +render_bp = Blueprint('render', __name__) + +@render_bp.route('/api/render-dot-svg', methods=['POST']) +def render_dot_svg(): + """Render DOT source (already generated by a previous /api/generate-* call) to SVG.""" + data = request.get_json() + dot_content = data.get('dot', '') + + client_ip = request.remote_addr + route = request.path + log_to_csv(client_ip, route, f"dot={compact_for_log(dot_content)}") + + if not dot_content or not dot_content.strip(): + return ResponseBuilder.validation_error("dot", "DOT content cannot be empty.") + + svg = dot_to_svg(dot_content) + if svg is None: + return ResponseBuilder.error( + "dot -Tsvg conversion failed (is graphviz installed?).", + status_code=500 + ) + + return ResponseBuilder.success(data={"svg": svg}) \ No newline at end of file diff --git a/webapp/backend/services/__init__.py b/webapp/backend/services/__init__.py index 86df585..703305f 100644 --- a/webapp/backend/services/__init__.py +++ b/webapp/backend/services/__init__.py @@ -9,7 +9,9 @@ from .clusterService import ( get_namespaces, get_resource_types, get_current_context, + get_contexts, ) +from .utils import dot_to_svg __all__ = [ 'DiagramResult', @@ -21,5 +23,7 @@ __all__ = [ 'get_namespaces', 'get_resource_types', 'get_current_context', + 'get_contexts', + 'dot_to_svg', ] diff --git a/webapp/backend/services/clusterService.py b/webapp/backend/services/clusterService.py index 0770ab4..b0c4a05 100644 --- a/webapp/backend/services/clusterService.py +++ b/webapp/backend/services/clusterService.py @@ -7,9 +7,12 @@ import tempfile from typing import List, Optional, Dict, Any from constants import MIME_TYPES +from utils import get_app_logger, log_unexpected_error from .models import DiagramResult from .file_manager import FileManager -from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json +from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json, redact_temp_paths + +logger = get_app_logger(__name__) COMMON_RESOURCE_TYPES = frozenset({ 'pods', 'services', 'deployments', 'replicasets', 'statefulsets', @@ -33,68 +36,104 @@ _CANNOT_REACH_API = ( ) -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.""" +def _connection_error_message(error_msg: str, fallback_msg: str) -> str: + """Return 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( + return ( "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) + return _CANNOT_REACH_API if "connection refused" in error_msg.lower(): - raise RuntimeError( + return ( "Connection to the Kubernetes API server was refused. " "Make sure your cluster is running and the API server is accessible." ) - raise RuntimeError(fallback_msg) + return 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. - """ +def _run_kubectl(cmd: List[str], timeout: int) -> tuple[Optional[subprocess.CompletedProcess], Optional[str]]: try: - return subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout) + return subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=timeout), None except FileNotFoundError: - raise RuntimeError(_KUBECTL_NOT_FOUND) + return None, _KUBECTL_NOT_FOUND except subprocess.TimeoutExpired: - raise RuntimeError( + return None, ( 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.""" +def get_contexts() -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]: + """Retrieve the list of kubectl contexts configured locally, marking which one is current.""" try: - proc = _run_kubectl(["kubectl", "get", "namespaces", "-o", "json"], timeout=20) + proc, error = _run_kubectl(["kubectl", "config", "get-contexts", "-o", "name"], timeout=10) + if error: + return None, error + if proc.returncode != 0: + error_msg = proc.stderr.strip() if proc.stderr else f"kubectl exited with code {proc.returncode}" + return None, _connection_error_message(error_msg, f"kubectl error while fetching contexts: {error_msg[:200]}") + + names = [line.strip() for line in proc.stdout.splitlines() if line.strip()] + current, _ = get_current_context() + return [{"name": name, "current": name == current} for name in names], None + except Exception: + return None, log_unexpected_error(logger, "fetching contexts") + + +def _is_known_context(context: str) -> bool: + """Check context against the real list of locally configured kubectl contexts.""" + contexts, error = get_contexts() + if error or not contexts: + return False + return any(c["name"] == context for c in contexts) + + +def get_namespaces(context: Optional[str] = None) -> tuple[Optional[List[str]], Optional[str]]: + """Retrieve the sorted list of namespace names from the connected Kubernetes cluster via kubectl.""" + if context and not _is_known_context(context): + return None, f"Unknown kubectl context: {context!r}" + try: + cmd = ["kubectl"] + if context: + cmd.extend(["--context", context]) + cmd.extend(["get", "namespaces", "-o", "json"]) + proc, error = _run_kubectl(cmd, timeout=20) + if error: + return None, error + if proc.returncode != 0: + error_msg = proc.stderr.strip() if proc.stderr else f"kubectl exited with code {proc.returncode}" + return None, _connection_error_message(error_msg, f"kubectl error while fetching namespaces: {error_msg[:200]}") 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)}") + return sorted([item["metadata"]["name"] for item in result.get("items", [])]), None + except json.JSONDecodeError: + return None, log_unexpected_error(logger, "parsing kubectl output") + except Exception: + return None, log_unexpected_error(logger, "fetching namespaces") -def get_resource_types() -> List[Dict[str, Any]]: +def get_resource_types(context: Optional[str] = None) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]: """ 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. """ + if context and not _is_known_context(context): + return None, f"Unknown kubectl context: {context!r}" try: - proc = _run_kubectl( - ["kubectl", "api-resources", "--verbs=list", "--no-headers"], timeout=30 - ) + cmd = ["kubectl"] + if context: + cmd.extend(["--context", context]) + cmd.extend(["api-resources", "--verbs=list", "--no-headers"]) + proc, error = _run_kubectl(cmd, timeout=30) + if error: + return None, error + if proc.returncode != 0: + error_msg = proc.stderr.strip() if proc.stderr else f"kubectl exited with code {proc.returncode}" + return None, _connection_error_message(error_msg, f"kubectl error while fetching resource types: {error_msg[:200]}") + resources = [] seen = set() @@ -123,46 +162,42 @@ def get_resource_types() -> List[Dict[str, Any]]: }) 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)}") + return resources, None + except Exception: + return None, log_unexpected_error(logger, "fetching resource types") -def get_current_context() -> str: +def get_current_context() -> tuple[Optional[str], Optional[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)}") + proc, error = _run_kubectl(["kubectl", "config", "current-context"], timeout=5) + if error: + return None, error + if proc.returncode != 0: + error_msg = proc.stderr.strip() if proc.stderr else f"kubectl exited with code {proc.returncode}" + return None, f"No active kubectl context found: {error_msg}" + return proc.stdout.strip(), None + except Exception: + return None, log_unexpected_error(logger, "fetching context") -def _make_diagrams_error(stdout: str, stderr: str, cmd: List[str]) -> DiagramResult: +def _make_diagrams_error(stdout: str, stderr: str, cmd: List[str], *paths: str) -> DiagramResult: """Return a DiagramResult describing why kubectl-diagrams failed.""" + command = redact_temp_paths(" ".join(cmd), *paths) 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), + command=command, stdout=stdout, stderr=stderr, ) return DiagramResult( success=False, error="kubectl-diagrams failed. See command output below.", - command=" ".join(cmd), + command=command, stdout=stdout, stderr=stderr, ) @@ -174,11 +209,16 @@ def generate_from_cluster( all_namespaces: bool = False, output_format: str = "png", extra_args: str = "", - without_namespace: bool = False + without_namespace: bool = False, + context: Optional[str] = None ) -> DiagramResult: """Generate diagram using kubectl-diagrams plugin directly.""" cmd: List[str] = [] + requested_output = png_output = dot_output = None try: + if context and not _is_known_context(context): + return DiagramResult(success=False, error=f"Unknown kubectl context: {context!r}") + resources_arg = ','.join(resource_types) base_name = f"cluster-diagram-{uuid.uuid4().hex[:8]}" base_path = os.path.join(tempfile.gettempdir(), base_name) @@ -187,6 +227,9 @@ def generate_from_cluster( cmd = ["kubectl-diagrams", resources_arg] + if context: + cmd.extend(["--context", context]) + if all_namespaces: cmd.append("--all-namespaces") elif namespace: @@ -201,21 +244,21 @@ def generate_from_cluster( cmd.append("--without-namespace") if extra_args.strip(): - cmd.extend(parse_extra_args(extra_args)) + cmd.extend(parse_extra_args(extra_args, "kubectl-diagrams")) 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) + return _make_diagrams_error(stdout_output, stderr_output, cmd, requested_output, png_output, dot_output) if output_format == "dot_json": if not os.path.exists(dot_output): return DiagramResult( success=False, - error=f"Output file not found: {dot_output}", - command=" ".join(cmd), + error=f"Output file not found: {os.path.basename(dot_output)}", + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -224,7 +267,7 @@ def generate_from_cluster( return DiagramResult( success=False, error="dot -Tjson conversion failed (is graphviz installed?).", - command=" ".join(cmd), + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -234,8 +277,8 @@ def generate_from_cluster( if output_info is None: return DiagramResult( success=False, - error=f"Output file not found: {requested_output}", - command=" ".join(cmd), + error=f"Output file not found: {os.path.basename(requested_output)}", + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -251,7 +294,7 @@ def generate_from_cluster( 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), + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -268,11 +311,11 @@ def generate_from_cluster( return DiagramResult( success=False, error="Command timed out. The cluster might be slow or unresponsive.", - command=" ".join(cmd) or "kubectl-diagrams" + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output) or "kubectl-diagrams" ) - except Exception as e: + except Exception: return DiagramResult( success=False, - error=f"Unexpected error: {str(e)}", - command=" ".join(cmd) or "kubectl-diagrams" + error=log_unexpected_error(logger, "generating diagram from cluster"), + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output) or "kubectl-diagrams" ) diff --git a/webapp/backend/services/file_manager.py b/webapp/backend/services/file_manager.py index 9048404..7591492 100644 --- a/webapp/backend/services/file_manager.py +++ b/webapp/backend/services/file_manager.py @@ -4,6 +4,8 @@ import tempfile from typing import Optional from contextlib import contextmanager +from .utils import get_safe_format_extension + class FileManager: """Temporary file manager.""" @staticmethod @@ -57,9 +59,14 @@ class FileManager: output_format: Requested output format Returns: tuple: (requested format_path, fallback png_path) + + Raises: + ValueError: If output_format is not a supported/known format. """ + safe_ext = get_safe_format_extension(output_format) + base_without_ext = os.path.splitext(base_path)[0] - requested_output = f"{base_without_ext}.{output_format}" + requested_output = f"{base_without_ext}.{safe_ext}" png_output = f"{base_without_ext}.png" return requested_output, png_output diff --git a/webapp/backend/services/helmService.py b/webapp/backend/services/helmService.py index 64412e8..d8afdf4 100644 --- a/webapp/backend/services/helmService.py +++ b/webapp/backend/services/helmService.py @@ -1,12 +1,17 @@ """Service for generating diagrams from Helm charts.""" import subprocess import os +import tempfile +import uuid from urllib.parse import urlparse from constants import MIME_TYPES +from utils import InputValidator, get_app_logger, log_unexpected_error from .models import DiagramResult from .file_manager import FileManager -from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json +from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json, get_safe_format_extension, redact_temp_paths + +logger = get_app_logger(__name__) def generate_from_helm( @@ -25,23 +30,34 @@ def generate_from_helm( Returns: DiagramResult: Result of the generation """ - # Extract base name for output file + safe_ext = get_safe_format_extension(output_format) + + if not InputValidator.is_valid_helm_url(chart_url): + raise ValueError("Invalid Helm chart URL format.") + + # Friendly display name derived from the chart, sanitized. Used only for + # the filename metadata returned to the client, never for a real path. parsed = urlparse(chart_url) - base_name = os.path.basename(parsed.path).replace(".tgz", "").replace(".tar.gz", "") + display_name = os.path.basename(parsed.path).replace(".tgz", "").replace(".tar.gz", "") # OCI URLs use the last path segment as chart name if chart_url.startswith('oci://'): - base_name = chart_url.rstrip('/').split('/')[-1] + display_name = chart_url.rstrip('/').split('/')[-1] - dot_output = os.path.abspath(f"{base_name}.dot") if output_format == "dot_json" else None - requested_output = os.path.abspath(f"{base_name}.{output_format}") - png_output = os.path.abspath(f"{base_name}.png") + display_name = InputValidator.sanitize_filename(display_name) or "chart" + + # Actual server-side output path: fully random, no link to user input. + output_base = os.path.join(tempfile.gettempdir(), f"helm-diagram-{uuid.uuid4().hex}") + + dot_output = f"{output_base}.dot" if output_format == "dot_json" else None + requested_output = f"{output_base}.{safe_ext}" + png_output = f"{output_base}.png" try: # Command uses helm-diagrams instead of helm cmd = ["helm-diagrams", chart_url, "-o", dot_output or requested_output] if extra_args.strip(): - cmd.extend(parse_extra_args(extra_args)) + cmd.extend(parse_extra_args(extra_args, "helm-diagrams")) # Run the command and capture output proc = subprocess.run(cmd, check=False, capture_output=True, text=True) @@ -79,7 +95,7 @@ def generate_from_helm( return DiagramResult( success=False, error=main_error, - command=" ".join(cmd), + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -89,8 +105,8 @@ def generate_from_helm( if not os.path.exists(dot_output): return DiagramResult( success=False, - error=f"Output file not found: {dot_output}", - command=" ".join(cmd), + error=f"Output file not found: {os.path.basename(dot_output)}", + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -99,7 +115,7 @@ def generate_from_helm( return DiagramResult( success=False, error="dot -Tjson conversion failed (is graphviz installed?).", - command=" ".join(cmd), + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -111,7 +127,7 @@ def generate_from_helm( return DiagramResult( success=False, error=f"Output file not found (looked for {os.path.basename(requested_output)} and {os.path.basename(png_output)}).", - command=" ".join(cmd), + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -131,25 +147,18 @@ def generate_from_helm( success=True, diagram=encoded, mime_type=MIME_TYPES.get(produced_format, "application/octet-stream"), - filename=f"{base_name}.{produced_format}", + filename=f"{display_name}.{produced_format}", message=message.strip(), - command=" ".join(cmd), + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) - except ValueError as e: + except Exception: FileManager.cleanup_files(requested_output, png_output, dot_output) return DiagramResult( success=False, - error=str(e), - command=" ".join(cmd) if 'cmd' in locals() else None - ) - except Exception as e: - FileManager.cleanup_files(requested_output, png_output, dot_output) - return DiagramResult( - success=False, - error=f"Internal error: {e}", - command=" ".join(cmd) if 'cmd' in locals() else None + error=log_unexpected_error(logger, "generating diagram from Helm chart"), + command=redact_temp_paths(" ".join(cmd), requested_output, png_output, dot_output) if 'cmd' in locals() else None ) diff --git a/webapp/backend/services/helmfileService.py b/webapp/backend/services/helmfileService.py index b6f2189..a3fe1d3 100644 --- a/webapp/backend/services/helmfileService.py +++ b/webapp/backend/services/helmfileService.py @@ -3,9 +3,12 @@ import subprocess import os from constants import MIME_TYPES +from utils import get_app_logger, log_unexpected_error from .models import DiagramResult from .file_manager import FileManager -from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json +from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json, get_safe_format_extension, redact_temp_paths + +logger = get_app_logger(__name__) def generate_from_helmfile( @@ -26,8 +29,10 @@ def generate_from_helmfile( Returns: DiagramResult: Result of the generation """ + safe_ext = get_safe_format_extension(output_format) + with FileManager.create_temp_file(helmfile_content, suffix=".yaml", mode='wb') as temp_helmfile_path: - output_path = temp_helmfile_path + f".{output_format}" + output_path = temp_helmfile_path + f".{safe_ext}" dot_output_path = temp_helmfile_path + ".dot" if output_format == "dot_json" else None try: @@ -47,7 +52,7 @@ def generate_from_helmfile( return DiagramResult( success=False, error="Helmfile template failed. See command output below.", - command=" ".join(template_cmd), + command=redact_temp_paths(" ".join(template_cmd), temp_helmfile_path), stdout="", stderr=helm_err or "" ) @@ -57,7 +62,7 @@ def generate_from_helmfile( if without_namespace: cmd.append("--without-namespace") if extra_args.strip(): - cmd.extend(parse_extra_args(extra_args)) + cmd.extend(parse_extra_args(extra_args, "kube-diagrams")) kube_proc = subprocess.run( cmd, @@ -75,7 +80,7 @@ def generate_from_helmfile( return DiagramResult( success=False, error="kube-diagrams failed", - command=f"{' '.join(template_cmd)} | {' '.join(cmd)}", + command=redact_temp_paths(f"{' '.join(template_cmd)} | {' '.join(cmd)}", temp_helmfile_path, output_path, dot_output_path), stdout=stdout_output, stderr=stderr_output ) @@ -84,8 +89,8 @@ def generate_from_helmfile( if not os.path.exists(dot_output_path): return DiagramResult( success=False, - error=f"Output file not found: {dot_output_path}", - command=f"{' '.join(template_cmd)} | {' '.join(cmd)}", + error=f"Output file not found: {os.path.basename(dot_output_path)}", + command=redact_temp_paths(f"{' '.join(template_cmd)} | {' '.join(cmd)}", temp_helmfile_path, output_path, dot_output_path), stdout=stdout_output, stderr=stderr_output ) @@ -94,15 +99,15 @@ def generate_from_helmfile( return DiagramResult( success=False, error="dot -Tjson conversion failed (is graphviz installed?).", - command=f"{' '.join(template_cmd)} | {' '.join(cmd)}", + command=redact_temp_paths(f"{' '.join(template_cmd)} | {' '.join(cmd)}", temp_helmfile_path, output_path, dot_output_path), stdout=stdout_output, stderr=stderr_output ) elif not os.path.exists(output_path): return DiagramResult( success=False, - error=f"Output file not found: {output_path}", - command=f"{' '.join(template_cmd)} | {' '.join(cmd)}", + error=f"Output file not found: {os.path.basename(output_path)}", + command=redact_temp_paths(f"{' '.join(template_cmd)} | {' '.join(cmd)}", temp_helmfile_path, output_path, dot_output_path), stdout=stdout_output, stderr=stderr_output ) @@ -119,22 +124,15 @@ def generate_from_helmfile( mime_type=MIME_TYPES.get(output_format, "application/octet-stream"), filename=f"helmfile-diagram.{output_format}", message="Helmfile diagram successfully generated.", - command=f"{' '.join(template_cmd)} | {' '.join(cmd)}", + command=redact_temp_paths(f"{' '.join(template_cmd)} | {' '.join(cmd)}", temp_helmfile_path, output_path, dot_output_path), stdout=stdout_output, stderr=stderr_output ) - except ValueError as e: + except Exception: FileManager.cleanup_files(output_path, dot_output_path) return DiagramResult( success=False, - error=str(e), - command=" ".join(cmd) if 'cmd' in locals() else None - ) - except Exception as e: - FileManager.cleanup_files(output_path, dot_output_path) - return DiagramResult( - success=False, - error=str(e), - command=" ".join(cmd) if 'cmd' in locals() else None + error=log_unexpected_error(logger, "generating diagram from Helmfile"), + command=redact_temp_paths(" ".join(cmd), temp_helmfile_path, output_path, dot_output_path) if 'cmd' in locals() else None ) \ No newline at end of file diff --git a/webapp/backend/services/manifestService.py b/webapp/backend/services/manifestService.py index 010b5e3..6dfee88 100644 --- a/webapp/backend/services/manifestService.py +++ b/webapp/backend/services/manifestService.py @@ -3,9 +3,12 @@ import os import subprocess from constants import MIME_TYPES +from utils import get_app_logger, log_unexpected_error from .models import DiagramResult from .file_manager import FileManager -from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json +from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json, redact_temp_paths + +logger = get_app_logger(__name__) def generate_from_manifest( @@ -39,7 +42,7 @@ def generate_from_manifest( if without_namespace: cmd.append("--without-namespace") if extra_args.strip(): - cmd.extend(parse_extra_args(extra_args)) + cmd.extend(parse_extra_args(extra_args, "kube-diagrams")) # Execution proc = subprocess.run(cmd, check=False, capture_output=True, text=True) @@ -52,7 +55,7 @@ def generate_from_manifest( return DiagramResult( success=False, error="KubeDiagrams failed. See command output below.", - command=" ".join(cmd), + command=redact_temp_paths(" ".join(cmd), tmp_manifest, requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -61,8 +64,8 @@ def generate_from_manifest( if not os.path.exists(dot_output): return DiagramResult( success=False, - error=f"Output file not found: {dot_output}", - command=" ".join(cmd), + error=f"Output file not found: {os.path.basename(dot_output)}", + command=redact_temp_paths(" ".join(cmd), tmp_manifest, requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -71,7 +74,7 @@ def generate_from_manifest( return DiagramResult( success=False, error="dot -Tjson conversion failed (is graphviz installed?).", - command=" ".join(cmd), + command=redact_temp_paths(" ".join(cmd), tmp_manifest, requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -82,8 +85,8 @@ def generate_from_manifest( if not output_info: return DiagramResult( success=False, - error=f"Output file not found (looked for {requested_output} and {png_output}).", - command=" ".join(cmd), + error=f"Output file not found (looked for {os.path.basename(requested_output)} and {os.path.basename(png_output)}).", + command=redact_temp_paths(" ".join(cmd), tmp_manifest, requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) @@ -101,23 +104,16 @@ def generate_from_manifest( mime_type=MIME_TYPES.get(produced_format, "application/octet-stream"), filename=f"{base_name}.{produced_format}", message="Diagram successfully generated.", - command=" ".join(cmd), + command=redact_temp_paths(" ".join(cmd), tmp_manifest, requested_output, png_output, dot_output), stdout=stdout_output, stderr=stderr_output ) - except ValueError as e: + except Exception: FileManager.cleanup_files(requested_output, png_output, dot_output) return DiagramResult( success=False, - error=str(e), - command=" ".join(cmd) if 'cmd' in locals() else None - ) - except Exception as e: - FileManager.cleanup_files(requested_output, png_output, dot_output) - return DiagramResult( - success=False, - error=f"Internal error: {e}", - command=" ".join(cmd) if 'cmd' in locals() else None + error=log_unexpected_error(logger, "generating diagram from manifest"), + command=redact_temp_paths(" ".join(cmd), tmp_manifest, requested_output, png_output, dot_output) if 'cmd' in locals() else None ) diff --git a/webapp/backend/services/utils.py b/webapp/backend/services/utils.py index c8ac7dc..8c72693 100644 --- a/webapp/backend/services/utils.py +++ b/webapp/backend/services/utils.py @@ -6,7 +6,15 @@ import re import shlex import subprocess -from constants import TEXT_FORMATS +from constants import TEXT_FORMATS, MIME_TYPES +from utils import InputValidator + +def redact_temp_paths(text: str, *paths: str) -> str: + for path in paths: + if path: + text = text.replace(path, os.path.basename(path)) + return text + def has_fatal_error(stdout_txt: str, stderr_txt: str) -> bool: """ @@ -22,25 +30,57 @@ def has_fatal_error(stdout_txt: str, stderr_txt: str) -> bool: return ("error:" in (stdout_txt or "").lower()) or ("error:" in (stderr_txt or "").lower()) -def parse_extra_args(extra_args: str) -> list[str]: +_SAFE_FORMAT_EXTENSIONS = {fmt: fmt for fmt in MIME_TYPES} + + +def get_safe_format_extension(output_format: str) -> str: """ - Parse a string of extra CLI arguments using shell-like tokenization. + Look up output_format in a hardcoded allowlist and return the matching + literal extension string. + + Args: + output_format: Output format to check + + Returns: + str: The same format string, sourced from a hardcoded mapping + + Raises: + ValueError: If output_format is not in MIME_TYPES + """ + try: + return _SAFE_FORMAT_EXTENSIONS[output_format] + except KeyError: + raise ValueError(f"Unsupported output format: {output_format!r}") + + +def parse_extra_args(extra_args: str, tool: str) -> list[str]: + """ + Parse a string of extra CLI arguments using shell-like tokenization, + rejecting any flag not in that tool's allowlist (EXTRA_ARGS_ALLOWED_FLAGS). Args: extra_args: Space-separated argument string (may include quoted tokens) + tool: Key into EXTRA_ARGS_ALLOWED_FLAGS identifying the target CLI tool Returns: list[str]: Parsed argument tokens Raises: - ValueError: If the argument string has invalid shell syntax + ValueError: If the argument string has invalid shell syntax, or + contains a flag not allowed for this tool """ if not extra_args or not extra_args.strip(): return [] try: - return shlex.split(extra_args.strip()) - except Exception as e: - raise ValueError(f"Invalid extraArgs: {e}") + tokens = shlex.split(extra_args.strip()) + except Exception: + raise ValueError("Invalid extraArgs: could not parse the value (check for unmatched quotes).") + + bad_flag = InputValidator.find_disallowed_flag(tokens, tool) + if bad_flag: + raise ValueError(f"Extra arg flag '{bad_flag}' is not allowed.") + + return tokens def encode_content(content: bytes, output_format: str) -> str: @@ -126,6 +166,39 @@ def dot_to_dot_json(dot_path: str, dot_json_path: str) -> bool: return False +def dot_to_svg(dot_text: str) -> str | None: + """ + Render DOT source to SVG via `dot -Tsvg`, then fix local icon paths into + GitHub CDN URLs. dot embeds icons in the SVG as xlink:href pointing to + the absolute local filesystem path used at generation time, which the + browser can't resolve. + + Args: + dot_text: DOT source text. + + Returns: + str | None: The rendered SVG text, or None if the conversion failed. + """ + try: + result = subprocess.run( + ["dot", "-Tsvg"], + input=dot_text, + capture_output=True, + text=True, + check=False + ) + if result.returncode != 0 or not result.stdout: + return None + + return re.sub( + r'xlink:href="([^"]*resources/[^"]+)"', + lambda m: f'xlink:href="{_local_path_to_github_url(m.group(1))}"', + result.stdout, + ) + except Exception: + return None + + def enrich_dot_json_with_positions(dot_json_path: str) -> None: """ Enrich a .dot_json file with layout coordinates computed by `dot -Tjson`. diff --git a/webapp/backend/utils/__init__.py b/webapp/backend/utils/__init__.py index ac5bc38..74538d8 100644 --- a/webapp/backend/utils/__init__.py +++ b/webapp/backend/utils/__init__.py @@ -1,5 +1,5 @@ """Package utils.""" -from .logger import AppLogger, get_app_logger +from .logger import AppLogger, get_app_logger, log_unexpected_error from .validators import InputValidator, ValidationError from .response_builder import ResponseBuilder from .access_logger import get_real_ip, log_request, log_request_compact @@ -7,6 +7,7 @@ from .access_logger import get_real_ip, log_request, log_request_compact __all__ = [ 'AppLogger', 'get_app_logger', + 'log_unexpected_error', 'InputValidator', 'ValidationError', 'ResponseBuilder', diff --git a/webapp/backend/utils/logger.py b/webapp/backend/utils/logger.py index 40d1f22..a1640d7 100644 --- a/webapp/backend/utils/logger.py +++ b/webapp/backend/utils/logger.py @@ -105,3 +105,22 @@ def get_app_logger(name: str = None) -> logging.Logger: name = frame.f_globals.get('__name__', 'app') return AppLogger.get_logger(name) + + +def log_unexpected_error(logger: logging.Logger, action: str) -> str: + """ + Log the full exception for an unexpected error and return a generic, + client-safe message describing it (no internal exception detail). + + Must be called from an except block (uses logger.exception, which + requires an active exception context to capture the traceback). + + Args: + logger: Logger to record the full exception on + action: Short description of what was being done when it occurred + + Returns: + str: Generic message safe to return to the client + """ + logger.exception(f"Unexpected error while {action}") + return f"Unexpected error while {action}. Check server logs for details." diff --git a/webapp/backend/utils/validators.py b/webapp/backend/utils/validators.py index f35745d..9b18172 100644 --- a/webapp/backend/utils/validators.py +++ b/webapp/backend/utils/validators.py @@ -1,7 +1,8 @@ """Validation of user inputs.""" import re +import shlex from typing import Optional, Tuple -from constants import MANIFEST_RE, KIND_RE +from constants import MANIFEST_RE, KIND_RE, EXTRA_ARGS_ALLOWED_FLAGS class ValidationError(Exception): @@ -12,12 +13,17 @@ class ValidationError(Exception): class InputValidator: """Validator for user inputs.""" - SUPPORTED_FORMATS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'pdf', 'dot', 'dot_json', 'drawio'] + SUPPORTED_FORMATS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'pdf', 'dot', 'dot_json', 'drawio', 'mermaid', 'd2'] - # Valid Pattern for url - HELM_URL_PATTERN = re.compile( - r'^(https?://|oci://|file://|[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+)' - ) + HELM_SCHEME_URL_PATTERN = re.compile(r'^(https?|oci|file)://[a-zA-Z0-9\-_./:~]*$') + HELM_CHART_REF_PATTERN = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9\-_]*/[a-zA-Z0-9\-_]+$') + + @classmethod + def is_valid_helm_url(cls, url: str) -> bool: + return bool( + cls.HELM_SCHEME_URL_PATTERN.fullmatch(url) + or cls.HELM_CHART_REF_PATTERN.fullmatch(url) + ) @classmethod def validate_manifest(cls, content: str) -> Tuple[bool, Optional[str]]: @@ -83,7 +89,7 @@ class InputValidator: url = url.strip() # Verify the helm url pattern - if not cls.HELM_URL_PATTERN.match(url): + if not cls.is_valid_helm_url(url): return False, "Invalid Helm chart URL format. Must start with http://, https://, oci://, file://, or be a chart reference." return True, None @@ -109,13 +115,35 @@ class InputValidator: return True, None - @classmethod - def validate_extra_args(cls, args: str) -> Tuple[bool, Optional[str]]: + @staticmethod + def find_disallowed_flag(tokens: list, tool: str) -> Optional[str]: """ - Validate extra args. + Return the first token that looks like a CLI flag not in that tool's + allowlist (EXTRA_ARGS_ALLOWED_FLAGS), or None if all tokens are allowed. + + Args: + tokens: Already-tokenized extra args (see shlex.split) + tool: Key into EXTRA_ARGS_ALLOWED_FLAGS identifying the target CLI tool + + Returns: + Optional[str]: The disallowed flag, or None if all tokens are allowed + """ + allowed_flags = EXTRA_ARGS_ALLOWED_FLAGS[tool] + for token in tokens: + if token.startswith('-'): + flag = token.split('=', 1)[0] + if flag not in allowed_flags: + return flag + return None + + @classmethod + def validate_extra_args(cls, args: str, tool: str) -> Tuple[bool, Optional[str]]: + """ + Validate extra args against the allowlist of flags for the given tool. Args: args: extra_args + tool: Key into EXTRA_ARGS_ALLOWED_FLAGS identifying the target CLI tool Returns: Tuple[bool, Optional[str]]: (is_valid, error_message) @@ -123,10 +151,18 @@ class InputValidator: if not args or not args.strip(): return True, None - dangerous_chars = [';', '&', '|', '`', '$', '(', ')'] - for char in dangerous_chars: - if char in args: - return False, f"Extra args contain dangerous character '{char}'" + try: + tokens = shlex.split(args.strip()) + except ValueError: + return False, "Invalid extraArgs: could not parse the value (check for unmatched quotes)." + + bad_flag = cls.find_disallowed_flag(tokens, tool) + if bad_flag: + allowed_flags = EXTRA_ARGS_ALLOWED_FLAGS[tool] + return False, ( + f"Extra arg flag '{bad_flag}' is not allowed. " + f"Allowed flags: {', '.join(sorted(allowed_flags))}" + ) return True, None @@ -181,7 +217,6 @@ class InputValidator: helmfile_keys = ["\nreleases:", "\nrepositories:", "\nhelmdefaults:", "\nenvironments:", "\ntemplates:"] return any(key in t for key in helmfile_keys) - # TODO: Not used yet @classmethod def sanitize_filename(cls, filename: str) -> str: """ diff --git a/webapp/frontend/package.json b/webapp/frontend/package.json index 699f37d..7209151 100644 --- a/webapp/frontend/package.json +++ b/webapp/frontend/package.json @@ -14,7 +14,10 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@tailwindcss/vite": "^4.1.18", + "@terrastruct/d2": "^0.1.33", "lucide-react": "^0.574.0", + "mermaid": "^11.16.0", + "monaco-editor": "^0.54.0", "monaco-yaml": "^5.5.1", "motion": "^12.34.1", "prop-types": "^15.8.1", diff --git a/webapp/frontend/src/App.jsx b/webapp/frontend/src/App.jsx index cf5aa11..da24de6 100644 --- a/webapp/frontend/src/App.jsx +++ b/webapp/frontend/src/App.jsx @@ -8,7 +8,7 @@ import { useHistory } from './hooks/useHistory.js'; function App() { const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [restoredItem, setRestoredItem] = useState(null); - const { history, addToHistory, removeFromHistory, clearHistory, getHistoryItem } = useHistory(); + const { history, addToHistory, removeFromHistory, clearHistory } = useHistory(); const handleRestoreFromHistory = (item) => { // Set the restored item to trigger restoration in the corresponding tab @@ -23,8 +23,8 @@ function App() { // Memoize historyContext to avoid recreating it on every render const historyContext = useMemo( - () => ({ addToHistory, getHistoryItem, restoredItem, clearRestoredItem }), - [addToHistory, getHistoryItem, restoredItem] + () => ({ addToHistory, restoredItem, clearRestoredItem }), + [addToHistory, restoredItem] ); return ( diff --git a/webapp/frontend/src/components/common/DiagramViewer.jsx b/webapp/frontend/src/components/common/DiagramViewer.jsx index 94b543d..230d4dc 100644 --- a/webapp/frontend/src/components/common/DiagramViewer.jsx +++ b/webapp/frontend/src/components/common/DiagramViewer.jsx @@ -1,14 +1,40 @@ /** * DiagramViewer Component * Universal component for rendering diagrams in all supported formats - * Supports: DOT_JSON (interactive), PDF, DOT (code), SVG, PNG, JPG, DRAWIO + * Supports: DOT_JSON (interactive), PDF, DOT, SVG, PNG, JPG, DRAWIO */ import { useRef, useEffect } from 'react'; -import { Code2, Copy } from 'lucide-react'; +import mermaid from 'mermaid'; import PanZoomContainer from './PanZoomContainer.jsx'; import LoadingSpinner from './LoadingSpinner.jsx'; import { OUTPUT_FORMATS } from '../../utils/constants.js'; +import { renderDotToSvg } from '../../services/diagramApi.js'; +import { useSvgDiagramRenderer } from '../../hooks/useSvgDiagramRenderer.js'; + +// Mermaid configuration for consistent styling across the app +mermaid.initialize({ + startOnLoad: false, + theme: 'default', + themeVariables: { edgeLabelBackground: '#4b5563', nodeTextColor: '#f9fafb' }, + // Default maxTextSize (50000) is too low for real cluster/namespace diagrams + maxTextSize: 1000000, + maxEdges: 2000, +}); + +let mermaidRenderId = 0; + +let mermaidQueue = Promise.resolve(); + +function renderMermaid(content) { + const id = `mermaid-diagram-${++mermaidRenderId}`; + const task = mermaidQueue.then(() => mermaid.render(id, content)); + mermaidQueue = task.then( + () => {}, + () => {} + ); + return task; +} /** * Embedded draw.io viewer using embed.diagrams.net with postMessage protocol. @@ -26,7 +52,7 @@ function DrawioViewer({ content }) { // Listen for draw.io init event and send the XML content useEffect(() => { const handleMessage = (event) => { - if (!event.origin.includes('diagrams.net')) return; + if (event.origin !== 'https://embed.diagrams.net') return; try { const data = JSON.parse(event.data); if (data.event === 'init') { @@ -67,6 +93,116 @@ function DrawioViewer({ content }) { ); } +function DiagramRenderError({ formatLabel, message }) { + return ( +
+ {formatLabel} rendering error: {message} +
+ ); +} + +function SvgDiagramFrame({ containerRef, isRendering, loadingText }) { + return ( +
+ +
+ + {isRendering && ( +
+ +
+ )} +
+ ); +} + +function MermaidViewer({ content }) { + const { containerRef, error } = useSvgDiagramRenderer(renderMermaid, content, { + formatLabel: 'Mermaid', + }); + + if (error) { + return ; + } + + return ( + +
+ + ); +} + +let d2InstancePromise = null; + +let d2Queue = Promise.resolve(); + +function renderD2(content) { + const task = d2Queue.then(async () => { + if (!d2InstancePromise) { + d2InstancePromise = import('@terrastruct/d2').then(({ D2 }) => new D2()); + } + const d2 = await d2InstancePromise; + const result = await d2.compile(content); + const svg = await d2.render(result.diagram, result.renderOptions); + return { svg }; + }); + // Keep the queue alive even if this task fails, so later renders aren't stuck + d2Queue = task.then( + () => {}, + () => {} + ); + return task; +} + +function D2Viewer({ content }) { + const { containerRef, error, isRendering } = useSvgDiagramRenderer(renderD2, content, { + formatLabel: 'D2', + showSpinner: true, + }); + + if (error) { + return ; + } + + return ( + + ); +} + +/** + * DotViewer Component + */ +async function renderDot(content) { + const response = await renderDotToSvg(content); + if (!response.ok || !response.data?.svg) { + throw new Error(response.data?.error || 'Failed to render DOT diagram.'); + } + return { svg: response.data.svg }; +} + +function DotViewer({ content }) { + const { containerRef, error, isRendering } = useSvgDiagramRenderer(renderDot, content, { + formatLabel: 'DOT', + showSpinner: true, + }); + + if (error) { + return ; + } + + return ( + + ); +} + function DiagramViewer({ diagram, outputFormat, @@ -116,6 +252,16 @@ function DiagramViewer({ return ; } + // MERMAID - Client-side rendered viewer + if (ext === OUTPUT_FORMATS.MERMAID) { + return ; + } + + // D2 - Client-side rendered viewer + if (ext === OUTPUT_FORMATS.D2) { + return ; + } + // PDF - Embedded viewer if (ext === OUTPUT_FORMATS.PDF) { return ( @@ -131,50 +277,11 @@ function DiagramViewer({ ); } - // DOT - Source code viewer + // DOT - Server-rendered viewer (graphviz is already a mandatory backend + // dependency, so rendering there avoids adding a client-side WASM lib for + // yet another format) if (ext === OUTPUT_FORMATS.DOT) { - return ( -
-
-
- - DOT Source Code (Graphviz) -
- -
-
-
-            {diagram}
-          
-
-
- Tip: Use this DOT code with Graphviz tools (dot, neato, fdp, circo, twopi) or online - visualizers like{' '} - - GraphvizOnline - -
-
- ); + return ; } // SVG/PNG/JPG/JPEG - Image viewer with pan & zoom diff --git a/webapp/frontend/src/components/common/ExampleSelector.jsx b/webapp/frontend/src/components/common/ExampleSelector.jsx index f53fe03..09a77fd 100644 --- a/webapp/frontend/src/components/common/ExampleSelector.jsx +++ b/webapp/frontend/src/components/common/ExampleSelector.jsx @@ -19,7 +19,7 @@ function ExampleSelector({ type, onSelectExample, onSelectCliArgs }) { // For Helm Charts, the content is an object with url and cliArgs if (type === EXAMPLE_TYPES.HELM_CHART && typeof content === 'object') { onSelectExample(content.url); - if (onSelectCliArgs && content.cliArgs) { + if (onSelectCliArgs) { onSelectCliArgs(content.cliArgs); } } else { diff --git a/webapp/frontend/src/components/common/HistoryPanel.jsx b/webapp/frontend/src/components/common/HistoryPanel.jsx index e3810f7..cbd51a5 100644 --- a/webapp/frontend/src/components/common/HistoryPanel.jsx +++ b/webapp/frontend/src/components/common/HistoryPanel.jsx @@ -33,6 +33,7 @@ const HistoryPanel = ({ history, onRestore, onRemove, onClear, isOpen, onToggle manifest: 'Manifest', helm: 'Helm', helmfile: 'Helmfile', + cluster: 'Cluster', }; return labels[type] || type; }; @@ -42,6 +43,7 @@ const HistoryPanel = ({ history, onRestore, onRemove, onClear, isOpen, onToggle manifest: 'bg-blue-500/20 text-blue-400 border-blue-500/30', helm: 'bg-purple-500/20 text-purple-400 border-purple-500/30', helmfile: 'bg-green-500/20 text-green-400 border-green-500/30', + cluster: 'bg-orange-500/20 text-orange-400 border-orange-500/30', }; return colors[type] || 'bg-slate-500/20 text-slate-400 border-slate-500/30'; }; diff --git a/webapp/frontend/src/components/common/PanZoomContainer.jsx b/webapp/frontend/src/components/common/PanZoomContainer.jsx index b460124..6990355 100644 --- a/webapp/frontend/src/components/common/PanZoomContainer.jsx +++ b/webapp/frontend/src/components/common/PanZoomContainer.jsx @@ -61,11 +61,7 @@ export default function PanZoomContainer({ if (!el) return; const update = () => { - const prev = el.style.transform; - el.style.transform = 'none'; - const rect = el.getBoundingClientRect(); - el.style.transform = prev || ''; - setNaturalSize({ w: rect.width, h: rect.height }); + setNaturalSize({ w: el.offsetWidth, h: el.offsetHeight }); const vp = viewportRef.current; if (vp) setVpSize({ w: vp.clientWidth, h: vp.clientHeight }); @@ -251,7 +247,8 @@ export default function PanZoomContainer({ if (!naturalSize.w || !naturalSize.h || !vpSize.w || !vpSize.h) return; const sx = (vpSize.w - 2 * padding) / naturalSize.w; const sy = (vpSize.h - 2 * padding) / naturalSize.h; - const s = clamp(Math.min(sx, sy), minScale, maxScale); + + const s = Math.min(sx, sy, maxScale); const cw = naturalSize.w * s; const ch = naturalSize.h * s; const nx = (vpSize.w - cw) / 2; @@ -302,16 +299,32 @@ export default function PanZoomContainer({ }} >
- - - -
diff --git a/webapp/frontend/src/components/common/Tabs.jsx b/webapp/frontend/src/components/common/Tabs.jsx index 73b4afe..19212e0 100644 --- a/webapp/frontend/src/components/common/Tabs.jsx +++ b/webapp/frontend/src/components/common/Tabs.jsx @@ -66,7 +66,6 @@ function Tabs({ historyContext }) { Tabs.propTypes = { historyContext: PropTypes.shape({ addToHistory: PropTypes.func.isRequired, - getHistoryItem: PropTypes.func.isRequired, restoredItem: PropTypes.object, clearRestoredItem: PropTypes.func.isRequired, }), diff --git a/webapp/frontend/src/components/common/YamlEditor.jsx b/webapp/frontend/src/components/common/YamlEditor.jsx index 3604d86..8325734 100644 --- a/webapp/frontend/src/components/common/YamlEditor.jsx +++ b/webapp/frontend/src/components/common/YamlEditor.jsx @@ -1,5 +1,17 @@ -import Editor from '@monaco-editor/react'; +import Editor, { loader } from '@monaco-editor/react'; +import * as monacoEditor from 'monaco-editor'; import { configureMonacoYaml } from 'monaco-yaml'; +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import YamlWorker from 'monaco-yaml/yaml.worker?worker'; + +loader.config({ monaco: monacoEditor }); + +window.MonacoEnvironment = { + getWorker(_moduleId, label) { + if (label === 'yaml') return new YamlWorker(); + return new EditorWorker(); + }, +}; let configured = false; @@ -43,4 +55,4 @@ function YamlEditor({ value, onChange, path = 'file.yaml' }) { ); } -export default YamlEditor; \ No newline at end of file +export default YamlEditor; diff --git a/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx b/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx index 399d7ab..2d0a419 100644 --- a/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx +++ b/webapp/frontend/src/components/tabs/ClusterTab/ClusterInput.jsx @@ -24,7 +24,9 @@ function ClusterInput({ isSubmitting, onSubmit, // Cluster data (from useClusterData via index.jsx) - currentContext, + contexts, + selectedContext, + loadingContexts, namespaces, availableResourceTypes, loadingNamespaces, @@ -34,9 +36,10 @@ function ClusterInput({ filteredResourceTypes, commonVisible, otherVisible, - fetchContext, + fetchContexts, fetchNamespaces, handleRefreshResourceTypes, + handleContextChange, handleResourceTypeToggle, handleSelectCommon, handleSelectAll, @@ -51,12 +54,37 @@ function ClusterInput({

Cluster Resources

- {currentContext && ( -

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

+
+ + +
+ +

+ Select which kubectl context/cluster to generate the diagram from.

- )} +
{/* All Namespaces Checkbox */} @@ -80,10 +108,7 @@ function ClusterInput({
); @@ -226,8 +218,9 @@ function ClusterTab({ historyContext }) { ClusterTab.propTypes = { historyContext: PropTypes.shape({ + addToHistory: PropTypes.func.isRequired, restoredItem: PropTypes.object, - addToHistory: PropTypes.func, + clearRestoredItem: PropTypes.func.isRequired, }), }; diff --git a/webapp/frontend/src/components/tabs/HelmFileTab/index.jsx b/webapp/frontend/src/components/tabs/HelmFileTab/index.jsx index 7914fc6..e63fba9 100644 --- a/webapp/frontend/src/components/tabs/HelmFileTab/index.jsx +++ b/webapp/frontend/src/components/tabs/HelmFileTab/index.jsx @@ -3,14 +3,14 @@ * Main component that orchestrates HelmFile diagram generation */ -import { useState, useEffect, useRef } from 'react'; +import { useState } from 'react'; import PropTypes from 'prop-types'; -import { DEFAULTS } from '../../../utils/constants.js'; import { looksLikeManifest } from '../../../utils/validators.js'; import { generateHelmfileDiagram } from '../../../services/diagramApi.js'; import { useViewerSync } from '../../../hooks/useViewerSync.js'; import { useFileUpload } from '../../../hooks/useFileUpload.js'; import { useDiagramGeneration } from '../../../hooks/useDiagramGeneration.js'; +import { useHistorySync } from '../../../hooks/useHistorySync.js'; import { useScrollToOutput } from '../../../hooks/useScrollToOutput.js'; import HelmFileInput from './HelmFileInput.jsx'; import HelmFileOutput from './HelmFileOutput.jsx'; @@ -19,12 +19,13 @@ import ProgressBar from '../../common/ProgressBar.jsx'; function HelmFileTab({ historyContext }) { // Input states const [helmfileContent, setHelmfileContent] = useState(''); - const [outputFormat, setOutputFormat] = useState(DEFAULTS.OUTPUT_FORMAT); const [extraArgs, setExtraArgs] = useState(''); const [withoutNamespace, setWithoutNamespace] = useState(false); // Diagram generation hook const { + outputFormat, + handleOutputFormatChange, diagram, command, message, @@ -38,7 +39,6 @@ function HelmFileTab({ historyContext }) { progressStep, handleSubmit: handleDiagramSubmit, setErrorMessage, - resetOutput, restoreDiagram, } = useDiagramGeneration({ apiFunction: generateHelmfileDiagram, @@ -60,68 +60,23 @@ function HelmFileTab({ historyContext }) { // File upload handler const { createFileInputHandler } = useFileUpload(); - // Track previous outputFormat to detect changes - const prevOutputFormatRef = useRef(outputFormat); - const lastHistoryIdRef = useRef(null); - - // Reset output when output format changes (not on initial render) - useEffect(() => { - if (prevOutputFormatRef.current !== outputFormat && diagram) { - resetOutput(); - } - prevOutputFormatRef.current = outputFormat; - }, [outputFormat, diagram, resetOutput]); - - // Save to history when diagram is successfully generated - useEffect(() => { - if (diagram && progressStep === 'completed' && historyContext) { - const historyId = `helmfile-${Date.now()}`; - - // Avoid adding the same diagram multiple times - if (lastHistoryIdRef.current === historyId) { - return; - } - - const historyItem = { - id: historyId, - type: 'helmfile', - outputFormat, - diagram, - mimeType, - filename, - timestamp: new Date().toISOString(), - preview: helmfileContent.substring(0, 100), - input: { - helmfile: helmfileContent, - outputFormat, - extraArgs, - withoutNamespace, - }, - }; - - historyContext.addToHistory(historyItem); - lastHistoryIdRef.current = historyId; - } - }, [diagram, progressStep]); // minimal deps — avoids a save loop - - // Restore from history - useEffect(() => { - if (historyContext?.restoredItem && historyContext.restoredItem.type === 'helmfile') { - const item = historyContext.restoredItem; - - // Restore all input states - setHelmfileContent(item.input.helmfile); - setOutputFormat(item.input.outputFormat); - setExtraArgs(item.input.extraArgs || ''); - setWithoutNamespace(item.input.withoutNamespace || false); - - // Restore diagram output - restoreDiagram(item); - - // Clear the restored item - historyContext.clearRestoredItem(); - } - }, [historyContext?.restoredItem, restoreDiagram]); + useHistorySync({ + diagramType: 'helmfile', + historyContext, + outputFormat, + diagram, + mimeType, + filename, + progressStep, + restoreDiagram, + buildInput: () => ({ helmfile: helmfileContent, outputFormat, extraArgs, withoutNamespace }), + buildPreview: () => helmfileContent.substring(0, 100), + restoreInput: (input) => { + setHelmfileContent(input.helmfile || ''); + setExtraArgs(input.extraArgs || ''); + setWithoutNamespace(input.withoutNamespace || false); + }, + }); const handleSubmit = () => { handleDiagramSubmit({ @@ -138,7 +93,7 @@ function HelmFileTab({ historyContext }) { helmfileContent={helmfileContent} setHelmfileContent={setHelmfileContent} outputFormat={outputFormat} - setOutputFormat={setOutputFormat} + setOutputFormat={handleOutputFormatChange} extraArgs={extraArgs} setExtraArgs={setExtraArgs} withoutNamespace={withoutNamespace} @@ -180,7 +135,6 @@ function HelmFileTab({ historyContext }) { HelmFileTab.propTypes = { historyContext: PropTypes.shape({ addToHistory: PropTypes.func.isRequired, - getHistoryItem: PropTypes.func.isRequired, restoredItem: PropTypes.object, clearRestoredItem: PropTypes.func.isRequired, }), diff --git a/webapp/frontend/src/components/tabs/HelmTab/index.jsx b/webapp/frontend/src/components/tabs/HelmTab/index.jsx index b777d19..e377bad 100644 --- a/webapp/frontend/src/components/tabs/HelmTab/index.jsx +++ b/webapp/frontend/src/components/tabs/HelmTab/index.jsx @@ -3,13 +3,13 @@ * Main component that orchestrates Helm chart diagram generation */ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { DEFAULTS } from '../../../utils/constants.js'; import { isValidChartUrl } from '../../../utils/validators.js'; import { generateHelmDiagram } from '../../../services/diagramApi.js'; import { useViewerSync } from '../../../hooks/useViewerSync.js'; import { useDiagramGeneration } from '../../../hooks/useDiagramGeneration.js'; +import { useHistorySync } from '../../../hooks/useHistorySync.js'; import { useScrollToOutput } from '../../../hooks/useScrollToOutput.js'; import HelmInput from './HelmInput.jsx'; import HelmOutput from './HelmOutput.jsx'; @@ -18,12 +18,13 @@ import HelmOutput from './HelmOutput.jsx'; function HelmTab({ historyContext }) { // Input states const [chartUrl, setChartUrl] = useState(''); - const [outputFormat, setOutputFormat] = useState(DEFAULTS.OUTPUT_FORMAT); const [extraArgs, setExtraArgs] = useState(''); const [inputError, setInputError] = useState(''); // Diagram generation hook const { + outputFormat, + handleOutputFormatChange, diagram, command, message, @@ -37,7 +38,6 @@ function HelmTab({ historyContext }) { progressStep, handleSubmit: handleDiagramSubmit, setErrorMessage: setBackendError, - resetOutput, restoreDiagram, } = useDiagramGeneration({ apiFunction: generateHelmDiagram, @@ -56,67 +56,23 @@ function HelmTab({ historyContext }) { // Auto-scroll to output when diagram is ready const outputRef = useScrollToOutput(progressStep); - // Track previous outputFormat to detect changes - const prevOutputFormatRef = useRef(outputFormat); - const lastHistoryIdRef = useRef(null); - - // Reset output when output format changes (not on initial render) - useEffect(() => { - if (prevOutputFormatRef.current !== outputFormat && diagram) { - resetOutput(); - } - prevOutputFormatRef.current = outputFormat; - }, [outputFormat, diagram, resetOutput]); - - // Save to history when diagram is successfully generated - useEffect(() => { - if (diagram && progressStep === 'completed' && historyContext) { - const historyId = `helm-${Date.now()}`; - - // Avoid adding the same diagram multiple times - if (lastHistoryIdRef.current === historyId) { - return; - } - - const historyItem = { - id: historyId, - type: 'helm', - outputFormat, - diagram, - mimeType, - filename, - timestamp: new Date().toISOString(), - preview: chartUrl, - input: { - chart: chartUrl, - outputFormat, - extraArgs, - }, - }; - - historyContext.addToHistory(historyItem); - lastHistoryIdRef.current = historyId; - } - }, [diagram, progressStep]); // minimal deps — avoids a save loop - - // Restore from history - useEffect(() => { - if (historyContext?.restoredItem && historyContext.restoredItem.type === 'helm') { - const item = historyContext.restoredItem; - - // Restore all input states - setChartUrl(item.input.chart); - setOutputFormat(item.input.outputFormat); - setExtraArgs(item.input.extraArgs || ''); + useHistorySync({ + diagramType: 'helm', + historyContext, + outputFormat, + diagram, + mimeType, + filename, + progressStep, + restoreDiagram, + buildInput: () => ({ chart: chartUrl, outputFormat, extraArgs }), + buildPreview: () => chartUrl, + restoreInput: (input) => { + setChartUrl(input.chart || ''); + setExtraArgs(input.extraArgs || ''); setInputError(''); // Clear any validation errors - - // Restore diagram output - restoreDiagram(item); - - // Clear the restored item - historyContext.clearRestoredItem(); - } - }, [historyContext?.restoredItem, restoreDiagram]); + }, + }); // Validate chart URL on change useEffect(() => { @@ -164,7 +120,7 @@ function HelmTab({ historyContext }) { chartUrl={chartUrl} setChartUrl={setChartUrl} outputFormat={outputFormat} - setOutputFormat={setOutputFormat} + setOutputFormat={handleOutputFormatChange} extraArgs={extraArgs} setExtraArgs={setExtraArgs} inputError={inputError} @@ -203,7 +159,6 @@ function HelmTab({ historyContext }) { HelmTab.propTypes = { historyContext: PropTypes.shape({ addToHistory: PropTypes.func.isRequired, - getHistoryItem: PropTypes.func.isRequired, restoredItem: PropTypes.object, clearRestoredItem: PropTypes.func.isRequired, }), diff --git a/webapp/frontend/src/components/tabs/ManifestTab/index.jsx b/webapp/frontend/src/components/tabs/ManifestTab/index.jsx index c83bdb9..ee151c4 100644 --- a/webapp/frontend/src/components/tabs/ManifestTab/index.jsx +++ b/webapp/frontend/src/components/tabs/ManifestTab/index.jsx @@ -3,14 +3,14 @@ * Main component that orchestrates Manifest diagram generation */ -import { useState, useEffect, useRef } from 'react'; +import { useState } from 'react'; import PropTypes from 'prop-types'; -import { DEFAULTS } from '../../../utils/constants.js'; import { looksLikeHelmfile } from '../../../utils/validators.js'; import { generateManifestDiagram } from '../../../services/diagramApi.js'; import { useViewerSync } from '../../../hooks/useViewerSync.js'; import { useFileUpload } from '../../../hooks/useFileUpload.js'; import { useDiagramGeneration } from '../../../hooks/useDiagramGeneration.js'; +import { useHistorySync } from '../../../hooks/useHistorySync.js'; import { useScrollToOutput } from '../../../hooks/useScrollToOutput.js'; import ManifestInput from './ManifestInput.jsx'; import ManifestOutput from './ManifestOutput.jsx'; @@ -19,12 +19,13 @@ import ManifestOutput from './ManifestOutput.jsx'; function ManifestTab({ historyContext }) { // Input states const [manifestContent, setManifestContent] = useState(''); - const [outputFormat, setOutputFormat] = useState(DEFAULTS.OUTPUT_FORMAT); const [extraArgs, setExtraArgs] = useState(''); const [withoutNamespace, setWithoutNamespace] = useState(false); // Diagram generation hook const { + outputFormat, + handleOutputFormatChange, diagram, command, message, @@ -38,7 +39,6 @@ function ManifestTab({ historyContext }) { progressStep, handleSubmit: handleDiagramSubmit, setErrorMessage, - resetOutput, restoreDiagram, } = useDiagramGeneration({ apiFunction: generateManifestDiagram, @@ -63,68 +63,23 @@ function ManifestTab({ historyContext }) { // File upload handler const { createFileInputHandler } = useFileUpload(); - // Track previous outputFormat to detect changes - const prevOutputFormatRef = useRef(outputFormat); - const lastHistoryIdRef = useRef(null); - - // Reset output when output format changes (not on initial render) - useEffect(() => { - if (prevOutputFormatRef.current !== outputFormat && diagram) { - resetOutput(); - } - prevOutputFormatRef.current = outputFormat; - }, [outputFormat, diagram, resetOutput]); - - // Save to history when diagram is successfully generated - useEffect(() => { - if (diagram && progressStep === 'completed' && historyContext) { - const historyId = `manifest-${Date.now()}`; - - // Avoid adding the same diagram multiple times - if (lastHistoryIdRef.current === historyId) { - return; - } - - const historyItem = { - id: historyId, - type: 'manifest', - outputFormat, - diagram, - mimeType, - filename, - timestamp: new Date().toISOString(), - preview: manifestContent.substring(0, 100), - input: { - manifest: manifestContent, - outputFormat, - extraArgs, - withoutNamespace, - }, - }; - - historyContext.addToHistory(historyItem); - lastHistoryIdRef.current = historyId; - } - }, [diagram, progressStep]); // minimal deps — avoids a save loop - - // Restore from history - useEffect(() => { - if (historyContext?.restoredItem && historyContext.restoredItem.type === 'manifest') { - const item = historyContext.restoredItem; - - // Restore all input states - setManifestContent(item.input.manifest); - setOutputFormat(item.input.outputFormat); - setExtraArgs(item.input.extraArgs || ''); - setWithoutNamespace(item.input.withoutNamespace || false); - - // Restore diagram output using the hook function - restoreDiagram(item); - - // Clear the restored item - historyContext.clearRestoredItem(); - } - }, [historyContext?.restoredItem, restoreDiagram]); + useHistorySync({ + diagramType: 'manifest', + historyContext, + outputFormat, + diagram, + mimeType, + filename, + progressStep, + restoreDiagram, + buildInput: () => ({ manifest: manifestContent, outputFormat, extraArgs, withoutNamespace }), + buildPreview: () => manifestContent.substring(0, 100), + restoreInput: (input) => { + setManifestContent(input.manifest || ''); + setExtraArgs(input.extraArgs || ''); + setWithoutNamespace(input.withoutNamespace || false); + }, + }); const handleSubmit = () => { handleDiagramSubmit({ @@ -141,7 +96,7 @@ function ManifestTab({ historyContext }) { manifestContent={manifestContent} setManifestContent={setManifestContent} outputFormat={outputFormat} - setOutputFormat={setOutputFormat} + setOutputFormat={handleOutputFormatChange} extraArgs={extraArgs} setExtraArgs={setExtraArgs} withoutNamespace={withoutNamespace} @@ -183,7 +138,6 @@ function ManifestTab({ historyContext }) { ManifestTab.propTypes = { historyContext: PropTypes.shape({ addToHistory: PropTypes.func.isRequired, - getHistoryItem: PropTypes.func.isRequired, restoredItem: PropTypes.object, clearRestoredItem: PropTypes.func.isRequired, }), diff --git a/webapp/frontend/src/hooks/useClusterData.js b/webapp/frontend/src/hooks/useClusterData.js index 737b394..d88725a 100644 --- a/webapp/frontend/src/hooks/useClusterData.js +++ b/webapp/frontend/src/hooks/useClusterData.js @@ -1,14 +1,15 @@ import { useState, useEffect, useRef } from 'react'; import { toast } from 'sonner'; import { - getClusterContext, + getClusterContexts, 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. + * Manages cluster connectivity state: available kubectl contexts, namespaces, + * resource types, and the selected context. Handles all fetch logic, + * auto-selection, and resource-type selection handlers. * * @param {Object} params * @param {string[]} params.resourceTypes - Currently selected resource type names @@ -28,7 +29,9 @@ export function useClusterData({ setAllNamespaces, setErrorMessage, }) { - const [currentContext, setCurrentContext] = useState(''); + const [contexts, setContexts] = useState([]); + const [selectedContext, setSelectedContext] = useState(''); + const [loadingContexts, setLoadingContexts] = useState(false); const [namespaces, setNamespaces] = useState([]); const [availableResourceTypes, setAvailableResourceTypes] = useState([]); const [loadingNamespaces, setLoadingNamespaces] = useState(false); @@ -37,7 +40,7 @@ export function useClusterData({ const resourceTypesAutoSelectedRef = useRef(false); useEffect(() => { - fetchContext(); + fetchContexts(); fetchNamespaces(); fetchResourceTypes(); }, []); @@ -52,21 +55,30 @@ export function useClusterData({ } }, [availableResourceTypes, resourceTypes, setResourceTypes]); - const fetchContext = async () => { + const fetchContexts = async () => { + setLoadingContexts(true); try { - const response = await getClusterContext(); - if (response.ok && response.data?.context) { - setCurrentContext(response.data.context); + const response = await getClusterContexts(); + if (response.ok && response.data?.contexts) { + setContexts(response.data.contexts); + setSelectedContext((prev) => { + if (prev && response.data.contexts.some((c) => c.name === prev)) return prev; + const current = response.data.contexts.find((c) => c.current); + return current?.name || response.data.contexts[0]?.name || ''; + }); } } catch { - // Silent — context is informational, not blocking + // Silent — context list is informational, not blocking + } finally { + setLoadingContexts(false); } }; - const fetchNamespaces = async () => { + const fetchNamespaces = async (contextOverride) => { + const context = contextOverride !== undefined ? contextOverride : selectedContext; setLoadingNamespaces(true); try { - const response = await getClusterNamespaces(); + const response = await getClusterNamespaces(context); if (response.ok && response.data?.namespaces) { setNamespaces(response.data.namespaces); } else { @@ -99,10 +111,11 @@ export function useClusterData({ } }; - const fetchResourceTypes = async () => { + const fetchResourceTypes = async (contextOverride) => { + const context = contextOverride !== undefined ? contextOverride : selectedContext; setLoadingResourceTypes(true); try { - const response = await getClusterResourceTypes(); + const response = await getClusterResourceTypes(context); if (response.ok && response.data?.resourceTypes) { setAvailableResourceTypes(response.data.resourceTypes); } else { @@ -126,6 +139,15 @@ export function useClusterData({ } }; + const handleContextChange = (newContext) => { + setSelectedContext(newContext); + setNamespace(''); + setResourceTypes([]); + resourceTypesAutoSelectedRef.current = false; + fetchNamespaces(newContext); + fetchResourceTypes(newContext); + }; + const handleResourceTypeToggle = (type) => { setResourceTypes((prev) => prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type] @@ -201,10 +223,12 @@ export function useClusterData({ return { // Data - currentContext, + contexts, + selectedContext, namespaces, availableResourceTypes, // Loading flags + loadingContexts, loadingNamespaces, loadingResourceTypes, // Search / filtered views @@ -214,10 +238,11 @@ export function useClusterData({ commonVisible, otherVisible, // Fetch actions - fetchContext, + fetchContexts, fetchNamespaces, handleRefreshResourceTypes, // Selection handlers + handleContextChange, handleResourceTypeToggle, handleSelectCommon, handleSelectAll, diff --git a/webapp/frontend/src/hooks/useDiagramGeneration.js b/webapp/frontend/src/hooks/useDiagramGeneration.js index d9fb16b..ee90546 100644 --- a/webapp/frontend/src/hooks/useDiagramGeneration.js +++ b/webapp/frontend/src/hooks/useDiagramGeneration.js @@ -4,7 +4,7 @@ */ import { useState, useCallback } from 'react'; -import { OUTPUT_FORMATS } from '../utils/constants.js'; +import { OUTPUT_FORMATS, DEFAULTS } from '../utils/constants.js'; import logger from '../utils/logger.js'; import { hasFatalErrors } from '../services/diagramApi.js'; import toastUtil from '../utils/toast.js'; @@ -18,6 +18,8 @@ import toastUtil from '../utils/toast.js'; * @returns {Object} - State and handlers for diagram generation */ export function useDiagramGeneration({ apiFunction, validateInput, diagramType = 'diagram' }) { + const [outputFormat, setOutputFormat] = useState(DEFAULTS.OUTPUT_FORMAT); + // Output states const [diagram, setDiagram] = useState(''); const [command, setCommand] = useState(''); @@ -51,6 +53,15 @@ export function useDiagramGeneration({ apiFunction, validateInput, diagramType = setErrorMessage(''); setProgressStep('idle'); }, []); + const handleOutputFormatChange = useCallback( + (newFormat) => { + if (newFormat !== outputFormat && diagram) { + resetOutput(); + } + setOutputFormat(newFormat); + }, + [outputFormat, diagram, resetOutput] + ); /** * Handle diagram generation submission @@ -196,6 +207,7 @@ export function useDiagramGeneration({ apiFunction, validateInput, diagramType = * @param {Object} historyItem - History item with diagram and metadata */ const restoreDiagram = useCallback((historyItem) => { + setOutputFormat(historyItem.outputFormat || DEFAULTS.OUTPUT_FORMAT); setDiagram(historyItem.diagram || ''); setCommand(''); setMessage(historyItem.message || ''); @@ -209,6 +221,11 @@ export function useDiagramGeneration({ apiFunction, validateInput, diagramType = }, []); return { + // Output format + outputFormat, + setOutputFormat, + handleOutputFormatChange, + // Output states diagram, command, diff --git a/webapp/frontend/src/hooks/useFileUpload.js b/webapp/frontend/src/hooks/useFileUpload.js index 3958838..f2dd8de 100644 --- a/webapp/frontend/src/hooks/useFileUpload.js +++ b/webapp/frontend/src/hooks/useFileUpload.js @@ -28,7 +28,7 @@ export function useFileUpload() { const fileName = file.name.toLowerCase(); const validExtensions = FILE_INPUT.ACCEPT.split(',').map((ext) => ext.trim()); const hasValidExtension = validExtensions.some((ext) => - fileName.endsWith(ext.replace('*', '')) + fileName.endsWith(ext.replaceAll('*', '')) ); if (!hasValidExtension) { diff --git a/webapp/frontend/src/hooks/useHistory.js b/webapp/frontend/src/hooks/useHistory.js index 4232efc..dfe4dd0 100644 --- a/webapp/frontend/src/hooks/useHistory.js +++ b/webapp/frontend/src/hooks/useHistory.js @@ -101,21 +101,10 @@ export function useHistory() { logger.info('History cleared'); }, []); - /** - * Get item by ID - */ - const getHistoryItem = useCallback( - (id) => { - return history.find((item) => item.id === id); - }, - [history] - ); - return { history, addToHistory, removeFromHistory, clearHistory, - getHistoryItem, }; } diff --git a/webapp/frontend/src/hooks/useHistorySync.js b/webapp/frontend/src/hooks/useHistorySync.js new file mode 100644 index 0000000..1efde40 --- /dev/null +++ b/webapp/frontend/src/hooks/useHistorySync.js @@ -0,0 +1,76 @@ +import { useEffect, useRef } from 'react'; + +/** + * @param {Object} config + * @param {string} config.diagramType - History item type (manifest, helm, helmfile, cluster) + * @param {Object} config.historyContext - { addToHistory, restoredItem, clearRestoredItem } + * @param {string} config.outputFormat + * @param {string} config.diagram + * @param {string} config.mimeType + * @param {string} config.filename + * @param {string} config.progressStep + * @param {Function} config.restoreDiagram - From useDiagramGeneration + * @param {Function} config.buildInput - () => object, snapshot of the tab's current input fields + * @param {Function} [config.buildPreview] - () => string, short preview text shown in the panel + * @param {Function} config.restoreInput - (input: object) => void, applies restored input fields back to state + */ +export function useHistorySync({ + diagramType, + historyContext, + outputFormat, + diagram, + mimeType, + filename, + progressStep, + restoreDiagram, + buildInput, + buildPreview, + restoreInput, +}) { + const lastHistoryIdRef = useRef(null); + // Set right before restoreDiagram() sets diagram+progressStep to 'completed', + // so the save effect below doesn't mistake a restore for a new generation + // and re-save the same diagram as a near-duplicate entry. + const isRestoringRef = useRef(false); + + // Save to history when a diagram is successfully generated + useEffect(() => { + if (isRestoringRef.current) { + isRestoringRef.current = false; + return; + } + if (diagram && progressStep === 'completed' && historyContext) { + const historyId = `${diagramType}-${Date.now()}`; + + // Avoid adding the same diagram multiple times + if (lastHistoryIdRef.current === historyId) { + return; + } + + historyContext.addToHistory({ + id: historyId, + type: diagramType, + outputFormat, + diagram, + mimeType, + filename, + timestamp: new Date().toISOString(), + preview: buildPreview ? buildPreview() : '', + input: buildInput(), + }); + lastHistoryIdRef.current = historyId; + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- minimal deps, avoids a save loop + }, [diagram, progressStep]); + + // Restore from history + useEffect(() => { + if (historyContext?.restoredItem && historyContext.restoredItem.type === diagramType) { + const item = historyContext.restoredItem; + restoreInput(item.input || {}); + isRestoringRef.current = true; + restoreDiagram(item); + historyContext.clearRestoredItem(); + } + }, [historyContext?.restoredItem, restoreDiagram]); +} diff --git a/webapp/frontend/src/hooks/useScrollToOutput.js b/webapp/frontend/src/hooks/useScrollToOutput.js index e8194fb..8866119 100644 --- a/webapp/frontend/src/hooks/useScrollToOutput.js +++ b/webapp/frontend/src/hooks/useScrollToOutput.js @@ -17,4 +17,4 @@ export function useScrollToOutput(progressStep) { }, [progressStep]); return outputRef; -} \ No newline at end of file +} diff --git a/webapp/frontend/src/hooks/useSvgDiagramRenderer.js b/webapp/frontend/src/hooks/useSvgDiagramRenderer.js new file mode 100644 index 0000000..3eaa202 --- /dev/null +++ b/webapp/frontend/src/hooks/useSvgDiagramRenderer.js @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from 'react'; + +function fixSvgIntrinsicSize(svgEl) { + const viewBox = svgEl?.getAttribute('viewBox'); + if (!svgEl || !viewBox) return; + const [, , vbWidth, vbHeight] = viewBox.split(' ').map(Number); + svgEl.setAttribute('width', vbWidth); + svgEl.setAttribute('height', vbHeight); + svgEl.style.maxWidth = ''; +} + +/** + * @param {Function} renderFn - (content) => Promise<{ svg: string, bindFunctions?: Function }> + * @param {string} content - Diagram source to render + * @param {Object} [options] + * @param {string} [options.formatLabel] - Used in the default error message (e.g. "D2") + * @param {boolean} [options.showSpinner] - Whether to expose a loading state while rendering + * @returns {{ containerRef: React.RefObject, error: string|null, isRendering: boolean }} + */ +export function useSvgDiagramRenderer( + renderFn, + content, + { formatLabel = '', showSpinner = false } = {} +) { + const containerRef = useRef(null); + const [error, setError] = useState(null); + const [isRendering, setIsRendering] = useState(showSpinner); + + useEffect(() => { + let cancelled = false; + + renderFn(content) + .then(({ svg, bindFunctions }) => { + if (cancelled || !containerRef.current) return; + containerRef.current.innerHTML = svg; + bindFunctions?.(containerRef.current); + fixSvgIntrinsicSize(containerRef.current.querySelector('svg')); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + setError(err?.message || `Failed to render ${formatLabel} diagram.`); + }) + .finally(() => { + if (!cancelled) setIsRendering(false); + }); + + return () => { + cancelled = true; + }; + }, [content]); + + return { containerRef, error, isRendering }; +} diff --git a/webapp/frontend/src/index.css b/webapp/frontend/src/index.css index cd9d8d1..0e30f33 100644 --- a/webapp/frontend/src/index.css +++ b/webapp/frontend/src/index.css @@ -42,6 +42,16 @@ button:hover { transform: scale(1.05); } +/* Pan/zoom overlay controls button colors. */ +.kd-controls button { + color: #1f2937; +} + +.kd-controls button:hover { + background-color: #f3f4f6; + transform: none; +} + .diagram-viewer svg { image-rendering: optimizeQuality; text-rendering: geometricPrecision; diff --git a/webapp/frontend/src/services/diagramApi.js b/webapp/frontend/src/services/diagramApi.js index 4d9b7b0..37c09dd 100644 --- a/webapp/frontend/src/services/diagramApi.js +++ b/webapp/frontend/src/services/diagramApi.js @@ -158,6 +158,7 @@ export async function generateHelmfileDiagram({ * @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 + * @param {string} [params.context] - kubectl context to use (defaults to the current one) * @returns {Promise} Response with diagram data */ export async function generateClusterDiagram({ @@ -167,12 +168,14 @@ export async function generateClusterDiagram({ outputFormat, extraArgs = '', withoutNamespace = false, + context = '', }) { logger.debug('Requesting Cluster diagram generation', { namespace, allNamespaces, outputFormat, withoutNamespace, + context, }); const response = await apiFetch(API_ENDPOINTS.GENERATE_CLUSTER, { @@ -183,6 +186,7 @@ export async function generateClusterDiagram({ outputFormat, extraArgs, withoutNamespace, + context, }), }); @@ -226,14 +230,45 @@ export async function getClusterContext() { } /** - * Get list of namespaces from Kubernetes cluster - * @returns {Promise} Response with namespaces list + * Get the list of kubectl contexts configured locally, each tagged with + * whether it's the current one. + * @returns {Promise} Response with contexts list */ -export async function getClusterNamespaces() { - logger.debug('Fetching cluster namespaces'); +export async function getClusterContexts() { + logger.debug('Fetching kubectl contexts'); try { - const response = await fetch(API_ENDPOINTS.CLUSTER_NAMESPACES, { + const response = await fetch(API_ENDPOINTS.CLUSTER_CONTEXTS, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + const data = await response.json(); + + if (response.ok) { + logger.info('kubectl contexts fetched', { count: data?.contexts?.length || 0 }); + } + + return { ok: response.ok, status: response.status, data }; + } catch (error) { + logger.error('Network error fetching kubectl contexts', { error }); + throw error; + } +} + +/** + * Get list of namespaces from Kubernetes cluster + * @param {string} [context] - kubectl context to query (defaults to the current one) + * @returns {Promise} Response with namespaces list + */ +export async function getClusterNamespaces(context = '') { + logger.debug('Fetching cluster namespaces', { context }); + + try { + const url = context + ? `${API_ENDPOINTS.CLUSTER_NAMESPACES}?context=${encodeURIComponent(context)}` + : API_ENDPOINTS.CLUSTER_NAMESPACES; + const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -269,13 +304,17 @@ export async function getClusterNamespaces() { * 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. + * @param {string} [context] - kubectl context to query (defaults to the current one) * @returns {Promise} Response with resource types list */ -export async function getClusterResourceTypes() { - logger.debug('Fetching cluster resource types'); +export async function getClusterResourceTypes(context = '') { + logger.debug('Fetching cluster resource types', { context }); try { - const response = await fetch(API_ENDPOINTS.CLUSTER_RESOURCE_TYPES, { + const url = context + ? `${API_ENDPOINTS.CLUSTER_RESOURCE_TYPES}?context=${encodeURIComponent(context)}` + : API_ENDPOINTS.CLUSTER_RESOURCE_TYPES; + const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -338,6 +377,28 @@ export async function submitFeedback({ note, comment, diagramType }) { return response; } +/** + * Render DOT source (already returned by a previous generate call) to SVG + * @param {string} dot - DOT source text + * @returns {Promise} Response with { svg } data + */ +export async function renderDotToSvg(dot) { + logger.debug('Requesting DOT-to-SVG render'); + + const response = await apiFetch(API_ENDPOINTS.RENDER_DOT_SVG, { + body: JSON.stringify({ dot }), + }); + + if (!response.ok) { + logger.warn('Failed to render DOT to SVG', { + statusCode: response.status, + error: response.data?.error, + }); + } + + return response; +} + /** * Check if output contains fatal errors * @param {string} stdout - Standard output @@ -357,8 +418,10 @@ export default { generateHelmfileDiagram, generateClusterDiagram, getClusterContext, + getClusterContexts, getClusterNamespaces, getClusterResourceTypes, submitFeedback, hasFatalErrors, + renderDotToSvg, }; diff --git a/webapp/frontend/src/utils/constants.js b/webapp/frontend/src/utils/constants.js index 374aa3c..c4fb0e1 100644 --- a/webapp/frontend/src/utils/constants.js +++ b/webapp/frontend/src/utils/constants.js @@ -8,6 +8,8 @@ export const OUTPUT_FORMATS = Object.freeze({ DOT: 'dot', DOT_JSON: 'dot_json', DRAWIO: 'drawio', + MERMAID: 'mermaid', + D2: 'd2', }); export const OUTPUT_FORMAT_LIST = Object.freeze([ @@ -19,6 +21,8 @@ export const OUTPUT_FORMAT_LIST = Object.freeze([ OUTPUT_FORMATS.DOT, OUTPUT_FORMATS.DOT_JSON, OUTPUT_FORMATS.DRAWIO, + OUTPUT_FORMATS.MERMAID, + OUTPUT_FORMATS.D2, ]); // API Endpoints @@ -29,10 +33,12 @@ export const API_ENDPOINTS = Object.freeze({ GENERATE_HELMFILE: '/api/generate-helmfile-diagram', GENERATE_CLUSTER: '/api/cluster/generate', CLUSTER_CONTEXT: '/api/cluster/context', + CLUSTER_CONTEXTS: '/api/cluster/contexts', CLUSTER_NAMESPACES: '/api/cluster/namespaces', CLUSTER_RESOURCE_TYPES: '/api/cluster/resource-types', SUBMIT_FEEDBACK: '/api/submit-feedback', EXAMPLES: '/api/examples', + RENDER_DOT_SVG: '/api/render-dot-svg', }); // Example types @@ -58,6 +64,8 @@ export const MIME_TYPES = Object.freeze({ [OUTPUT_FORMATS.DOT]: 'text/vnd.graphviz', [OUTPUT_FORMATS.DOT_JSON]: 'application/json', [OUTPUT_FORMATS.DRAWIO]: 'application/xml', + [OUTPUT_FORMATS.MERMAID]: 'text/vnd.mermaid', + [OUTPUT_FORMATS.D2]: 'text/vnd.d2', }); // Viewer message types for postMessage communication @@ -80,4 +88,6 @@ export const TEXT_FORMATS = Object.freeze([ OUTPUT_FORMATS.DOT, OUTPUT_FORMATS.DOT_JSON, OUTPUT_FORMATS.DRAWIO, + OUTPUT_FORMATS.MERMAID, + OUTPUT_FORMATS.D2, ]); diff --git a/webapp/frontend/vite.config.js b/webapp/frontend/vite.config.js index b032297..1962737 100644 --- a/webapp/frontend/vite.config.js +++ b/webapp/frontend/vite.config.js @@ -5,6 +5,10 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + optimizeDeps: { + exclude: ['@terrastruct/d2'], + include: ['path-browserify'], + }, server: { proxy: { '/api': {