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)
-
-
-
-
-
- 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}
+
+
+
+
+
+ Refresh
+
+
+
+
+ Select which kubectl context/cluster to generate the diagram from.
- )}
+
{/* All Namespaces Checkbox */}
@@ -80,10 +108,7 @@ function ClusterInput({
{
- fetchNamespaces();
- fetchContext();
- }}
+ onClick={() => fetchNamespaces()}
disabled={loadingNamespaces}
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
>
@@ -391,7 +416,14 @@ ClusterInput.propTypes = {
errorMessage: PropTypes.string,
isSubmitting: PropTypes.bool.isRequired,
onSubmit: PropTypes.func.isRequired,
- currentContext: PropTypes.string.isRequired,
+ contexts: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ current: PropTypes.bool.isRequired,
+ })
+ ).isRequired,
+ selectedContext: PropTypes.string.isRequired,
+ loadingContexts: PropTypes.bool.isRequired,
namespaces: PropTypes.arrayOf(PropTypes.string).isRequired,
availableResourceTypes: PropTypes.array.isRequired,
loadingNamespaces: PropTypes.bool.isRequired,
@@ -401,9 +433,10 @@ ClusterInput.propTypes = {
filteredResourceTypes: PropTypes.array.isRequired,
commonVisible: PropTypes.array.isRequired,
otherVisible: PropTypes.array.isRequired,
- fetchContext: PropTypes.func.isRequired,
+ fetchContexts: PropTypes.func.isRequired,
fetchNamespaces: PropTypes.func.isRequired,
handleRefreshResourceTypes: PropTypes.func.isRequired,
+ handleContextChange: PropTypes.func.isRequired,
handleResourceTypeToggle: PropTypes.func.isRequired,
handleSelectCommon: PropTypes.func.isRequired,
handleSelectAll: PropTypes.func.isRequired,
diff --git a/webapp/frontend/src/components/tabs/ClusterTab/index.jsx b/webapp/frontend/src/components/tabs/ClusterTab/index.jsx
index 088f5d5..6aab77b 100644
--- a/webapp/frontend/src/components/tabs/ClusterTab/index.jsx
+++ b/webapp/frontend/src/components/tabs/ClusterTab/index.jsx
@@ -2,12 +2,12 @@
* Cluster Tab Container
* Main component that orchestrates live cluster diagram generation
*/
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
-import { DEFAULTS } from '../../../utils/constants.js';
import { generateClusterDiagram } from '../../../services/diagramApi.js';
import { useViewerSync } from '../../../hooks/useViewerSync.js';
import { useDiagramGeneration } from '../../../hooks/useDiagramGeneration.js';
+import { useHistorySync } from '../../../hooks/useHistorySync.js';
import { useClusterData } from '../../../hooks/useClusterData.js';
import { useScrollToOutput } from '../../../hooks/useScrollToOutput.js';
import ClusterInput from './ClusterInput.jsx';
@@ -19,12 +19,13 @@ function ClusterTab({ historyContext }) {
const [namespace, setNamespace] = useState('');
const [resourceTypes, setResourceTypes] = useState([]);
const [allNamespaces, setAllNamespaces] = useState(false);
- const [outputFormat, setOutputFormat] = useState(DEFAULTS.OUTPUT_FORMAT);
const [extraArgs, setExtraArgs] = useState('');
const [withoutNamespace, setWithoutNamespace] = useState(false);
// Diagram generation hook
const {
+ outputFormat,
+ handleOutputFormatChange,
diagram,
command,
message,
@@ -38,7 +39,7 @@ function ClusterTab({ historyContext }) {
viewerKey,
progressStep,
handleSubmit: generateDiagram,
- resetOutput,
+ restoreDiagram,
} = useDiagramGeneration({
apiFunction: generateClusterDiagram,
validateInput: (params) => {
@@ -51,23 +52,14 @@ function ClusterTab({ historyContext }) {
diagramType: 'cluster',
});
- // Track previous outputFormat to detect changes
- const prevOutputFormatRef = useRef(outputFormat);
-
- // Reset output when output format changes (not on initial render)
- useEffect(() => {
- if (prevOutputFormatRef.current !== outputFormat && diagram) {
- resetOutput();
- }
- prevOutputFormatRef.current = outputFormat;
- }, [outputFormat, diagram, resetOutput]);
-
// Viewer synchronization hook for DOT_JSON format
const { viewerRef, handleViewerLoad } = useViewerSync({ diagram, outputFormat });
- // Cluster connectivity: namespaces, resource types, context, and all related handlers
+ // Cluster connectivity: contexts, namespaces, resource types, and all related handlers
const {
- currentContext,
+ contexts,
+ selectedContext,
+ loadingContexts,
namespaces,
availableResourceTypes,
loadingNamespaces,
@@ -77,9 +69,10 @@ function ClusterTab({ historyContext }) {
filteredResourceTypes,
commonVisible,
otherVisible,
- fetchContext,
+ fetchContexts,
fetchNamespaces,
handleRefreshResourceTypes,
+ handleContextChange,
handleResourceTypeToggle,
handleSelectCommon,
handleSelectAll,
@@ -99,45 +92,33 @@ function ClusterTab({ historyContext }) {
// Auto-scroll to output when diagram is ready
const outputRef = useScrollToOutput(progressStep);
- // History restoration
- const hasRestoredRef = useRef(false);
- useEffect(() => {
- if (historyContext?.restoredItem && !hasRestoredRef.current) {
- const item = historyContext.restoredItem;
- if (item.type === 'cluster') {
- setNamespace(item.input?.namespace || '');
- setResourceTypes(item.input?.resourceTypes || []);
- setAllNamespaces(item.input?.allNamespaces || false);
- setOutputFormat(item.outputFormat || DEFAULTS.OUTPUT_FORMAT);
- setExtraArgs(item.extraArgs || '');
- setWithoutNamespace(item.withoutNamespace || false);
- hasRestoredRef.current = true;
- }
- }
- }, [historyContext?.restoredItem]);
-
- // History tracking
- useEffect(() => {
- if (diagram && historyContext?.addToHistory) {
- historyContext.addToHistory({
- type: 'cluster',
- input: { namespace, resourceTypes, allNamespaces },
- outputFormat,
- extraArgs,
- withoutNamespace,
- result: {
- diagram,
- mimeType,
- filename,
- message,
- command,
- stdout,
- stderr,
- },
- timestamp: Date.now(),
- });
- }
- }, [diagram]);
+ useHistorySync({
+ diagramType: 'cluster',
+ historyContext,
+ outputFormat,
+ diagram,
+ mimeType,
+ filename,
+ progressStep,
+ restoreDiagram,
+ buildInput: () => ({
+ namespace,
+ resourceTypes,
+ allNamespaces,
+ extraArgs,
+ withoutNamespace,
+ context: selectedContext,
+ }),
+ buildPreview: () => (allNamespaces ? 'All namespaces' : namespace || 'No namespace selected'),
+ restoreInput: (input) => {
+ setNamespace(input.namespace || '');
+ setResourceTypes(input.resourceTypes || []);
+ setAllNamespaces(input.allNamespaces || false);
+ setExtraArgs(input.extraArgs || '');
+ setWithoutNamespace(input.withoutNamespace || false);
+ if (input.context) handleContextChange(input.context);
+ },
+ });
// Handle diagram generation with proper parameters
const handleGenerate = useCallback(() => {
@@ -148,6 +129,7 @@ function ClusterTab({ historyContext }) {
outputFormat,
extraArgs,
withoutNamespace,
+ context: selectedContext,
});
}, [
generateDiagram,
@@ -156,6 +138,7 @@ function ClusterTab({ historyContext }) {
allNamespaces,
outputFormat,
extraArgs,
+ selectedContext,
withoutNamespace,
]);
@@ -170,7 +153,7 @@ function ClusterTab({ historyContext }) {
resourceTypes={resourceTypes}
allNamespaces={allNamespaces}
outputFormat={outputFormat}
- setOutputFormat={setOutputFormat}
+ setOutputFormat={handleOutputFormatChange}
extraArgs={extraArgs}
setExtraArgs={setExtraArgs}
withoutNamespace={withoutNamespace}
@@ -178,7 +161,9 @@ function ClusterTab({ historyContext }) {
errorMessage={errorMessage}
isSubmitting={isSubmitting}
onSubmit={handleGenerate}
- currentContext={currentContext}
+ contexts={contexts}
+ selectedContext={selectedContext}
+ loadingContexts={loadingContexts}
namespaces={namespaces}
availableResourceTypes={availableResourceTypes}
loadingNamespaces={loadingNamespaces}
@@ -188,9 +173,10 @@ function ClusterTab({ historyContext }) {
filteredResourceTypes={filteredResourceTypes}
commonVisible={commonVisible}
otherVisible={otherVisible}
- fetchContext={fetchContext}
+ fetchContexts={fetchContexts}
fetchNamespaces={fetchNamespaces}
handleRefreshResourceTypes={handleRefreshResourceTypes}
+ handleContextChange={handleContextChange}
handleResourceTypeToggle={handleResourceTypeToggle}
handleSelectCommon={handleSelectCommon}
handleSelectAll={handleSelectAll}
@@ -218,7 +204,13 @@ function ClusterTab({ historyContext }) {
{/* Command Details Section - Full width below */}
{(command || stdout || stderr || message) && (
-
+
)}
);
@@ -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