mirror of
https://github.com/philippemerle/KubeDiagrams.git
synced 2026-08-16 14:16:14 +00:00
Merge pull request #80 from Sandor59100/feature/cluster-context-selector
Diagram format additions, cluster improvements, and backend security hardening
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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})
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center w-full h-48 text-red-500 text-sm p-4 text-center">
|
||||
{formatLabel} rendering error: {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SvgDiagramFrame({ containerRef, isRendering, loadingText }) {
|
||||
return (
|
||||
<div className="relative w-full h-[70vh]">
|
||||
<PanZoomContainer className="w-full h-full bg-white rounded-md border">
|
||||
<div ref={containerRef} className="diagram-viewer" />
|
||||
</PanZoomContainer>
|
||||
{isRendering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white rounded-md pointer-events-none">
|
||||
<LoadingSpinner size="lg" color="blue" text={loadingText} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MermaidViewer({ content }) {
|
||||
const { containerRef, error } = useSvgDiagramRenderer(renderMermaid, content, {
|
||||
formatLabel: 'Mermaid',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <DiagramRenderError formatLabel="Mermaid" message={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PanZoomContainer className="w-full h-[70vh] bg-white rounded-md border">
|
||||
<div ref={containerRef} className="diagram-viewer" />
|
||||
</PanZoomContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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 <DiagramRenderError formatLabel="D2" message={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SvgDiagramFrame
|
||||
containerRef={containerRef}
|
||||
isRendering={isRendering}
|
||||
loadingText="Rendering D2 diagram..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <DiagramRenderError formatLabel="DOT" message={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SvgDiagramFrame
|
||||
containerRef={containerRef}
|
||||
isRendering={isRendering}
|
||||
loadingText="Rendering DOT diagram..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagramViewer({
|
||||
diagram,
|
||||
outputFormat,
|
||||
@@ -116,6 +252,16 @@ function DiagramViewer({
|
||||
return <DrawioViewer key={viewerKey} content={diagram} />;
|
||||
}
|
||||
|
||||
// MERMAID - Client-side rendered viewer
|
||||
if (ext === OUTPUT_FORMATS.MERMAID) {
|
||||
return <MermaidViewer key={viewerKey} content={diagram} />;
|
||||
}
|
||||
|
||||
// D2 - Client-side rendered viewer
|
||||
if (ext === OUTPUT_FORMATS.D2) {
|
||||
return <D2Viewer key={viewerKey} content={diagram} />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="w-full h-[70vh] bg-gray-900 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between bg-gray-800 px-4 py-2 border-b border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 className="w-5 h-5 text-green-400" />
|
||||
<span className="text-sm font-semibold text-gray-300">DOT Source Code (Graphviz)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
navigator.clipboard.writeText(diagram);
|
||||
const btn = e.currentTarget;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="text-green-400">✓ Copied!</span>';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
}, 2000);
|
||||
}}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition flex items-center gap-1"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<pre className="text-sm text-gray-100 font-mono whitespace-pre">
|
||||
<code>{diagram}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="bg-gray-800 px-4 py-2 border-t border-gray-700 text-xs text-gray-400">
|
||||
Tip: Use this DOT code with Graphviz tools (dot, neato, fdp, circo, twopi) or online
|
||||
visualizers like{' '}
|
||||
<a
|
||||
href="https://dreampuf.github.io/GraphvizOnline/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
GraphvizOnline
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <DotViewer key={viewerKey} content={diagram} />;
|
||||
}
|
||||
|
||||
// SVG/PNG/JPG/JPEG - Image viewer with pan & zoom
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
}}
|
||||
>
|
||||
<div className="kd-controls absolute z-10 top-2 right-2 flex gap-2 pointer-events-auto">
|
||||
<button type="button" onClick={zoomOut} className="px-2 py-1 bg-white/80 rounded">
|
||||
<button
|
||||
type="button"
|
||||
onClick={zoomOut}
|
||||
className="px-2 py-1 bg-white shadow-md border border-gray-300 rounded text-gray-800 font-medium hover:bg-gray-100"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button type="button" onClick={zoomIn} className="px-2 py-1 bg-white/80 rounded">
|
||||
<button
|
||||
type="button"
|
||||
onClick={zoomIn}
|
||||
className="px-2 py-1 bg-white shadow-md border border-gray-300 rounded text-gray-800 font-medium hover:bg-gray-100"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button type="button" onClick={reset} className="px-2 py-1 bg-white/80 rounded">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="px-2 py-1 bg-white shadow-md border border-gray-300 rounded text-gray-800 font-medium hover:bg-gray-100"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button type="button" onClick={() => fit(false)} className="px-2 py-1 bg-white/80 rounded">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fit(false)}
|
||||
className="px-2 py-1 bg-white shadow-md border border-gray-300 rounded text-gray-800 font-medium hover:bg-gray-100"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
export default YamlEditor;
|
||||
|
||||
@@ -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({
|
||||
<h2 className="text-2xl font-bold">Cluster Resources</h2>
|
||||
</div>
|
||||
|
||||
{currentContext && (
|
||||
<p className="text-xs text-gray-400 -mt-2">
|
||||
Context:{' '}
|
||||
<code className="text-green-400 bg-gray-800 px-1.5 py-0.5 rounded">{currentContext}</code>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-white">Cluster Context</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchContexts}
|
||||
disabled={loadingContexts}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${loadingContexts ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
className="w-full p-3 rounded-lg bg-gray-700 text-white"
|
||||
value={selectedContext}
|
||||
onChange={(e) => handleContextChange(e.target.value)}
|
||||
disabled={loadingContexts || contexts.length === 0}
|
||||
>
|
||||
{contexts.length === 0 && <option value="">No context available</option>}
|
||||
{contexts.map((ctx) => (
|
||||
<option key={ctx.name} value={ctx.name}>
|
||||
{ctx.name}
|
||||
{ctx.current ? ' (current)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Select which kubectl context/cluster to generate the diagram from.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* All Namespaces Checkbox */}
|
||||
@@ -80,10 +108,7 @@ function ClusterInput({
|
||||
<label className="block text-sm font-medium text-white">Namespace</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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,
|
||||
|
||||
@@ -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) && (
|
||||
<CommandDetails command={command} stdout={stdout} stderr={stderr} message={message} titleClassName="text-white" />
|
||||
<CommandDetails
|
||||
command={command}
|
||||
stdout={stdout}
|
||||
stderr={stderr}
|
||||
message={message}
|
||||
titleClassName="text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -17,4 +17,4 @@ export function useScrollToOutput(progressStep) {
|
||||
}, [progressStep]);
|
||||
|
||||
return outputRef;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Object>} Response with diagram data
|
||||
*/
|
||||
export async function generateClusterDiagram({
|
||||
@@ -167,12 +168,14 @@ export async function generateClusterDiagram({
|
||||
outputFormat,
|
||||
extraArgs = '',
|
||||
withoutNamespace = false,
|
||||
context = '',
|
||||
}) {
|
||||
logger.debug('Requesting Cluster diagram generation', {
|
||||
namespace,
|
||||
allNamespaces,
|
||||
outputFormat,
|
||||
withoutNamespace,
|
||||
context,
|
||||
});
|
||||
|
||||
const response = await apiFetch(API_ENDPOINTS.GENERATE_CLUSTER, {
|
||||
@@ -183,6 +186,7 @@ export async function generateClusterDiagram({
|
||||
outputFormat,
|
||||
extraArgs,
|
||||
withoutNamespace,
|
||||
context,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -226,14 +230,45 @@ export async function getClusterContext() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of namespaces from Kubernetes cluster
|
||||
* @returns {Promise<Object>} Response with namespaces list
|
||||
* Get the list of kubectl contexts configured locally, each tagged with
|
||||
* whether it's the current one.
|
||||
* @returns {Promise<Object>} Response with contexts list
|
||||
*/
|
||||
export async function getClusterNamespaces() {
|
||||
logger.debug('Fetching cluster namespaces');
|
||||
export async function getClusterContexts() {
|
||||
logger.debug('Fetching kubectl contexts');
|
||||
|
||||
try {
|
||||
const response = await fetch(API_ENDPOINTS.CLUSTER_NAMESPACES, {
|
||||
const response = await fetch(API_ENDPOINTS.CLUSTER_CONTEXTS, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('kubectl contexts fetched', { count: data?.contexts?.length || 0 });
|
||||
}
|
||||
|
||||
return { ok: response.ok, status: response.status, data };
|
||||
} catch (error) {
|
||||
logger.error('Network error fetching kubectl contexts', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of namespaces from Kubernetes cluster
|
||||
* @param {string} [context] - kubectl context to query (defaults to the current one)
|
||||
* @returns {Promise<Object>} Response with namespaces list
|
||||
*/
|
||||
export async function getClusterNamespaces(context = '') {
|
||||
logger.debug('Fetching cluster namespaces', { context });
|
||||
|
||||
try {
|
||||
const url = context
|
||||
? `${API_ENDPOINTS.CLUSTER_NAMESPACES}?context=${encodeURIComponent(context)}`
|
||||
: API_ENDPOINTS.CLUSTER_NAMESPACES;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -269,13 +304,17 @@ export async function getClusterNamespaces() {
|
||||
* Each entry includes name, shortNames, namespaced scope, and isCommon flag.
|
||||
* The list is fetched once on tab open and cached in the component — use the
|
||||
* refresh button to re-query the cluster.
|
||||
* @param {string} [context] - kubectl context to query (defaults to the current one)
|
||||
* @returns {Promise<Object>} Response with resource types list
|
||||
*/
|
||||
export async function getClusterResourceTypes() {
|
||||
logger.debug('Fetching cluster resource types');
|
||||
export async function getClusterResourceTypes(context = '') {
|
||||
logger.debug('Fetching cluster resource types', { context });
|
||||
|
||||
try {
|
||||
const response = await fetch(API_ENDPOINTS.CLUSTER_RESOURCE_TYPES, {
|
||||
const url = context
|
||||
? `${API_ENDPOINTS.CLUSTER_RESOURCE_TYPES}?context=${encodeURIComponent(context)}`
|
||||
: API_ENDPOINTS.CLUSTER_RESOURCE_TYPES;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -338,6 +377,28 @@ export async function submitFeedback({ note, comment, diagramType }) {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render DOT source (already returned by a previous generate call) to SVG
|
||||
* @param {string} dot - DOT source text
|
||||
* @returns {Promise<Object>} Response with { svg } data
|
||||
*/
|
||||
export async function renderDotToSvg(dot) {
|
||||
logger.debug('Requesting DOT-to-SVG render');
|
||||
|
||||
const response = await apiFetch(API_ENDPOINTS.RENDER_DOT_SVG, {
|
||||
body: JSON.stringify({ dot }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to render DOT to SVG', {
|
||||
statusCode: response.status,
|
||||
error: response.data?.error,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if output contains fatal errors
|
||||
* @param {string} stdout - Standard output
|
||||
@@ -357,8 +418,10 @@ export default {
|
||||
generateHelmfileDiagram,
|
||||
generateClusterDiagram,
|
||||
getClusterContext,
|
||||
getClusterContexts,
|
||||
getClusterNamespaces,
|
||||
getClusterResourceTypes,
|
||||
submitFeedback,
|
||||
hasFatalErrors,
|
||||
renderDotToSvg,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ export const OUTPUT_FORMATS = Object.freeze({
|
||||
DOT: 'dot',
|
||||
DOT_JSON: 'dot_json',
|
||||
DRAWIO: 'drawio',
|
||||
MERMAID: 'mermaid',
|
||||
D2: 'd2',
|
||||
});
|
||||
|
||||
export const OUTPUT_FORMAT_LIST = Object.freeze([
|
||||
@@ -19,6 +21,8 @@ export const OUTPUT_FORMAT_LIST = Object.freeze([
|
||||
OUTPUT_FORMATS.DOT,
|
||||
OUTPUT_FORMATS.DOT_JSON,
|
||||
OUTPUT_FORMATS.DRAWIO,
|
||||
OUTPUT_FORMATS.MERMAID,
|
||||
OUTPUT_FORMATS.D2,
|
||||
]);
|
||||
|
||||
// API Endpoints
|
||||
@@ -29,10 +33,12 @@ export const API_ENDPOINTS = Object.freeze({
|
||||
GENERATE_HELMFILE: '/api/generate-helmfile-diagram',
|
||||
GENERATE_CLUSTER: '/api/cluster/generate',
|
||||
CLUSTER_CONTEXT: '/api/cluster/context',
|
||||
CLUSTER_CONTEXTS: '/api/cluster/contexts',
|
||||
CLUSTER_NAMESPACES: '/api/cluster/namespaces',
|
||||
CLUSTER_RESOURCE_TYPES: '/api/cluster/resource-types',
|
||||
SUBMIT_FEEDBACK: '/api/submit-feedback',
|
||||
EXAMPLES: '/api/examples',
|
||||
RENDER_DOT_SVG: '/api/render-dot-svg',
|
||||
});
|
||||
|
||||
// Example types
|
||||
@@ -58,6 +64,8 @@ export const MIME_TYPES = Object.freeze({
|
||||
[OUTPUT_FORMATS.DOT]: 'text/vnd.graphviz',
|
||||
[OUTPUT_FORMATS.DOT_JSON]: 'application/json',
|
||||
[OUTPUT_FORMATS.DRAWIO]: 'application/xml',
|
||||
[OUTPUT_FORMATS.MERMAID]: 'text/vnd.mermaid',
|
||||
[OUTPUT_FORMATS.D2]: 'text/vnd.d2',
|
||||
});
|
||||
|
||||
// Viewer message types for postMessage communication
|
||||
@@ -80,4 +88,6 @@ export const TEXT_FORMATS = Object.freeze([
|
||||
OUTPUT_FORMATS.DOT,
|
||||
OUTPUT_FORMATS.DOT_JSON,
|
||||
OUTPUT_FORMATS.DRAWIO,
|
||||
OUTPUT_FORMATS.MERMAID,
|
||||
OUTPUT_FORMATS.D2,
|
||||
]);
|
||||
|
||||
@@ -5,6 +5,10 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
optimizeDeps: {
|
||||
exclude: ['@terrastruct/d2'],
|
||||
include: ['path-browserify'],
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user