diff --git a/webapp/backend/services/clusterService.py b/webapp/backend/services/clusterService.py
index c73662a..e4f2e08 100644
--- a/webapp/backend/services/clusterService.py
+++ b/webapp/backend/services/clusterService.py
@@ -9,7 +9,7 @@ from typing import List, Optional, Dict, Any
from constants import MIME_TYPES
from .models import DiagramResult
from .file_manager import FileManager
-from .utils import parse_extra_args, has_fatal_error, encode_content
+from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json
COMMON_RESOURCE_TYPES = frozenset({
'pods', 'services', 'deployments', 'replicasets', 'statefulsets',
@@ -191,9 +191,9 @@ def generate_from_cluster(
elif namespace:
cmd.extend(["-n", namespace])
- cmd.extend(["-o", requested_output])
+ cmd.extend(["-o", dot_output or requested_output])
- if output_format != "png":
+ if output_format != "png" and not dot_output:
cmd.extend(["-f", output_format])
if without_namespace:
@@ -209,20 +209,40 @@ def generate_from_cluster(
if proc.returncode != 0 or has_fatal_error(stdout_output, stderr_output):
return _make_diagrams_error(stdout_output, stderr_output, cmd)
- output_info = FileManager.find_output_file(requested_output, png_output)
- if output_info is None:
- return DiagramResult(
- success=False,
- error=f"Output file not found: {requested_output}",
- command=" ".join(cmd),
- stdout=stdout_output,
- stderr=stderr_output
- )
- output_file, produced_format = output_info
+ 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),
+ stdout=stdout_output,
+ stderr=stderr_output
+ )
+ if not dot_to_dot_json(dot_output, requested_output):
+ FileManager.cleanup_files(dot_output)
+ return DiagramResult(
+ success=False,
+ error="dot -Tjson conversion failed (is graphviz installed?).",
+ command=" ".join(cmd),
+ stdout=stdout_output,
+ stderr=stderr_output
+ )
+ output_file, produced_format = requested_output, "dot_json"
+ else:
+ output_info = FileManager.find_output_file(requested_output, png_output)
+ if output_info is None:
+ return DiagramResult(
+ success=False,
+ error=f"Output file not found: {requested_output}",
+ command=" ".join(cmd),
+ stdout=stdout_output,
+ stderr=stderr_output
+ )
+ output_file, produced_format = output_info
content = FileManager.read_file_content(output_file, binary=True)
encoded = encode_content(content, produced_format)
- FileManager.cleanup_files(output_file)
+ FileManager.cleanup_files(output_file, dot_output)
return DiagramResult(
success=True,
diff --git a/webapp/backend/services/helmService.py b/webapp/backend/services/helmService.py
index ca1f2e7..64412e8 100644
--- a/webapp/backend/services/helmService.py
+++ b/webapp/backend/services/helmService.py
@@ -6,7 +6,7 @@ from urllib.parse import urlparse
from constants import MIME_TYPES
from .models import DiagramResult
from .file_manager import FileManager
-from .utils import parse_extra_args, has_fatal_error, encode_content
+from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json
def generate_from_helm(
@@ -33,12 +33,13 @@ def generate_from_helm(
if chart_url.startswith('oci://'):
base_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")
try:
# Command uses helm-diagrams instead of helm
- cmd = ["helm-diagrams", chart_url, "-o", f"{base_name}.{output_format}"]
+ cmd = ["helm-diagrams", chart_url, "-o", dot_output or requested_output]
if extra_args.strip():
cmd.extend(parse_extra_args(extra_args))
@@ -54,8 +55,8 @@ def generate_from_helm(
has_error = True
if has_error:
- FileManager.cleanup_files(requested_output, png_output)
-
+ FileManager.cleanup_files(requested_output, png_output, dot_output)
+
# logs for all errors
error_details = []
if "not found" in stderr_output.lower() or "404" in stderr_output:
@@ -83,28 +84,46 @@ def generate_from_helm(
stderr=stderr_output
)
- # Search for the output file
- output_info = FileManager.find_output_file(requested_output, png_output)
- if not output_info:
- 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),
- stdout=stdout_output,
- stderr=stderr_output
- )
-
- output_file, produced_format = output_info
-
note = ""
- if produced_format == "png" and output_format != "png":
- note = f"Requested format '{output_format}' is not available from helm-diagrams. Returned PNG instead."
+ 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),
+ stdout=stdout_output,
+ stderr=stderr_output
+ )
+ if not dot_to_dot_json(dot_output, requested_output):
+ FileManager.cleanup_files(dot_output)
+ return DiagramResult(
+ success=False,
+ error="dot -Tjson conversion failed (is graphviz installed?).",
+ command=" ".join(cmd),
+ stdout=stdout_output,
+ stderr=stderr_output
+ )
+ output_file, produced_format = requested_output, "dot_json"
+ else:
+ # Search for the output file
+ output_info = FileManager.find_output_file(requested_output, png_output)
+ if not output_info:
+ 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),
+ stdout=stdout_output,
+ stderr=stderr_output
+ )
+ output_file, produced_format = output_info
+ if produced_format == "png" and output_format != "png":
+ note = f"Requested format '{output_format}' is not available from helm-diagrams. Returned PNG instead."
content = FileManager.read_file_content(output_file, binary=True)
encoded = encode_content(content, produced_format)
# Cleaning
- FileManager.cleanup_files(requested_output, png_output)
+ FileManager.cleanup_files(requested_output, png_output, dot_output)
message = (note + " " if note else "") + "Helm diagram successfully generated."
@@ -120,14 +139,14 @@ def generate_from_helm(
)
except ValueError as e:
- FileManager.cleanup_files(requested_output, png_output)
+ 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)
+ FileManager.cleanup_files(requested_output, png_output, dot_output)
return DiagramResult(
success=False,
error=f"Internal error: {e}",
diff --git a/webapp/backend/services/helmfileService.py b/webapp/backend/services/helmfileService.py
index f3790e8..b6f2189 100644
--- a/webapp/backend/services/helmfileService.py
+++ b/webapp/backend/services/helmfileService.py
@@ -5,7 +5,7 @@ import os
from constants import MIME_TYPES
from .models import DiagramResult
from .file_manager import FileManager
-from .utils import parse_extra_args, has_fatal_error, encode_content
+from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json
def generate_from_helmfile(
@@ -28,6 +28,7 @@ def generate_from_helmfile(
"""
with FileManager.create_temp_file(helmfile_content, suffix=".yaml", mode='wb') as temp_helmfile_path:
output_path = temp_helmfile_path + f".{output_format}"
+ dot_output_path = temp_helmfile_path + ".dot" if output_format == "dot_json" else None
try:
# Command helmfile template
@@ -42,7 +43,7 @@ def generate_from_helmfile(
helm_output, helm_err = template_proc.communicate()
if template_proc.returncode != 0 or has_fatal_error("", helm_err):
- FileManager.cleanup_files(output_path)
+ FileManager.cleanup_files(output_path, dot_output_path)
return DiagramResult(
success=False,
error="Helmfile template failed. See command output below.",
@@ -52,7 +53,7 @@ def generate_from_helmfile(
)
# Command kube-diagrams
- cmd = ["kube-diagrams", "-", "-o", output_path]
+ cmd = ["kube-diagrams", "-", "-o", dot_output_path or output_path]
if without_namespace:
cmd.append("--without-namespace")
if extra_args.strip():
@@ -70,7 +71,7 @@ def generate_from_helmfile(
stderr_output = kube_proc.stderr or ""
if kube_proc.returncode != 0 or has_fatal_error(stdout_output, stderr_output):
- FileManager.cleanup_files(output_path)
+ FileManager.cleanup_files(output_path, dot_output_path)
return DiagramResult(
success=False,
error="kube-diagrams failed",
@@ -79,7 +80,25 @@ def generate_from_helmfile(
stderr=stderr_output
)
- if not os.path.exists(output_path):
+ if output_format == "dot_json":
+ 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)}",
+ stdout=stdout_output,
+ stderr=stderr_output
+ )
+ if not dot_to_dot_json(dot_output_path, output_path):
+ FileManager.cleanup_files(dot_output_path)
+ return DiagramResult(
+ success=False,
+ error="dot -Tjson conversion failed (is graphviz installed?).",
+ command=f"{' '.join(template_cmd)} | {' '.join(cmd)}",
+ 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}",
@@ -92,7 +111,7 @@ def generate_from_helmfile(
encoded = encode_content(content, output_format)
# Cleaning
- FileManager.cleanup_files(output_path)
+ FileManager.cleanup_files(output_path, dot_output_path)
return DiagramResult(
success=True,
@@ -106,14 +125,14 @@ def generate_from_helmfile(
)
except ValueError as e:
- FileManager.cleanup_files(output_path)
+ 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)
+ FileManager.cleanup_files(output_path, dot_output_path)
return DiagramResult(
success=False,
error=str(e),
diff --git a/webapp/backend/services/manifestService.py b/webapp/backend/services/manifestService.py
index 2b8f4d5..010b5e3 100644
--- a/webapp/backend/services/manifestService.py
+++ b/webapp/backend/services/manifestService.py
@@ -1,10 +1,11 @@
"""Service for generating diagrams from Kubernetes manifests."""
+import os
import subprocess
from constants import MIME_TYPES
from .models import DiagramResult
from .file_manager import FileManager
-from .utils import parse_extra_args, has_fatal_error, encode_content
+from .utils import parse_extra_args, has_fatal_error, encode_content, dot_to_dot_json
def generate_from_manifest(
@@ -30,9 +31,11 @@ def generate_from_manifest(
requested_output, png_output = FileManager.get_output_paths(tmp_manifest, output_format)
+ dot_output = requested_output.replace(".dot_json", ".dot") if output_format == "dot_json" else None
+
try:
# Command
- cmd = ["kube-diagrams", tmp_manifest, "-o", requested_output]
+ cmd = ["kube-diagrams", tmp_manifest, "-o", dot_output or requested_output]
if without_namespace:
cmd.append("--without-namespace")
if extra_args.strip():
@@ -45,7 +48,7 @@ def generate_from_manifest(
# Error verification
if proc.returncode != 0 or has_fatal_error(stdout_output, stderr_output):
- FileManager.cleanup_files(requested_output, png_output)
+ FileManager.cleanup_files(requested_output, png_output, dot_output)
return DiagramResult(
success=False,
error="KubeDiagrams failed. See command output below.",
@@ -54,23 +57,43 @@ def generate_from_manifest(
stderr=stderr_output
)
- # Output file verification
- output_info = FileManager.find_output_file(requested_output, png_output)
- if not output_info:
- return DiagramResult(
- success=False,
- error=f"Output file not found (looked for {requested_output} and {png_output}).",
- command=" ".join(cmd),
- stdout=stdout_output,
- stderr=stderr_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),
+ stdout=stdout_output,
+ stderr=stderr_output
+ )
+ if not dot_to_dot_json(dot_output, requested_output):
+ FileManager.cleanup_files(dot_output)
+ return DiagramResult(
+ success=False,
+ error="dot -Tjson conversion failed (is graphviz installed?).",
+ command=" ".join(cmd),
+ stdout=stdout_output,
+ stderr=stderr_output
+ )
+ output_file, produced_format = requested_output, "dot_json"
+ else:
+ # Output file verification
+ output_info = FileManager.find_output_file(requested_output, png_output)
+ if not output_info:
+ return DiagramResult(
+ success=False,
+ error=f"Output file not found (looked for {requested_output} and {png_output}).",
+ command=" ".join(cmd),
+ stdout=stdout_output,
+ stderr=stderr_output
+ )
+ output_file, produced_format = output_info
- output_file, produced_format = output_info
content = FileManager.read_file_content(output_file, binary=True)
encoded = encode_content(content, produced_format)
# Cleaning
- FileManager.cleanup_files(requested_output, png_output)
+ FileManager.cleanup_files(requested_output, png_output, dot_output)
return DiagramResult(
success=True,
@@ -84,14 +107,14 @@ def generate_from_manifest(
)
except ValueError as e:
- FileManager.cleanup_files(requested_output, png_output)
+ 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)
+ FileManager.cleanup_files(requested_output, png_output, dot_output)
return DiagramResult(
success=False,
error=f"Internal error: {e}",
diff --git a/webapp/backend/services/utils.py b/webapp/backend/services/utils.py
index e072f53..c8ac7dc 100644
--- a/webapp/backend/services/utils.py
+++ b/webapp/backend/services/utils.py
@@ -1,6 +1,10 @@
"""Utilities for diagram generation services."""
import base64
+import json
+import os
+import re
import shlex
+import subprocess
from constants import TEXT_FORMATS
@@ -55,4 +59,166 @@ def encode_content(content: bytes, output_format: str) -> str:
"""
if output_format in TEXT_FORMATS:
return content.decode("utf-8")
- return base64.b64encode(content).decode("utf-8")
\ No newline at end of file
+ return base64.b64encode(content).decode("utf-8")
+
+
+_GITHUB_ICONS_BASE = (
+ "https://raw.githubusercontent.com/mingrammer/diagrams/refs/heads/master/"
+)
+
+
+def _local_path_to_github_url(path: str) -> str:
+ """
+ Convert a local kube-diagrams icon path to its GitHub CDN URL.
+
+ The .dot file generated by kube-diagrams references icons via local
+ paths (site-packages/resources/...). Running `dot -Tjson` on it preserves
+ those local paths as-is, so this restores the GitHub URLs the browser
+ needs to load the icons.
+ """
+ m = re.search(r'resources/.+', path)
+ return _GITHUB_ICONS_BASE + m.group() if m else path
+
+
+def dot_to_dot_json(dot_path: str, dot_json_path: str) -> bool:
+ """
+ Convert a .dot file into a .dot_json file with layout coordinates via
+ `dot -Tjson`, then fix local icon paths into GitHub CDN URLs.
+
+ Going through a single kube-diagrams execution producing a .dot file,
+ then converting it locally via `dot -Tjson`, avoids the mismatched
+ gvids that occur when kube-diagrams is invoked twice for the same
+ diagram (once per output format).
+
+ Args:
+ dot_path: Path to the source .dot file.
+ dot_json_path: Output path for the enriched .dot_json file.
+
+ Returns:
+ bool: True if the conversion succeeded, False otherwise.
+ """
+ try:
+ result = subprocess.run(
+ ["dot", "-Tjson", dot_path],
+ capture_output=True,
+ check=False
+ )
+ if result.returncode != 0 or not result.stdout:
+ return False
+
+ data = json.loads(result.stdout.decode("utf-8"))
+
+ # Fix local paths into GitHub URLs in both image attributes and HTML labels
+ for obj in data.get("objects", []):
+ if obj.get("image"):
+ obj["image"] = _local_path_to_github_url(obj["image"])
+ if obj.get("label") and " None:
+ """
+ Enrich a .dot_json file with layout coordinates computed by `dot -Tjson`.
+
+ Strategy:
+ 1. Read the .dot_json produced by kube-diagrams.
+ 2. Look for the matching .dot file (same path, .dot extension).
+ If missing, skip (coordinates can't be recomputed without it).
+ 3. Run `dot -Tjson` on the .dot file to get the enriched JSON with pos/bb.
+ 4. Inject the `pos` (nodes) and `bb` (clusters/graph) fields into the .dot_json.
+ 5. Rewrite the enriched .dot_json.
+
+ Args:
+ dot_json_path: Path to the .dot_json file to enrich (modified in place).
+ """
+ dot_executable = "dot"
+ dot_path = dot_json_path.replace(".dot_json", ".dot")
+
+ if not os.path.exists(dot_path):
+ return # No source .dot file available
+
+ try:
+ # 1. Read the existing dot_json
+ with open(dot_json_path, "r", encoding="utf-8") as f:
+ original = json.load(f)
+
+ # 2. Run dot -Tjson on the .dot file to get coordinates
+ result = subprocess.run(
+ [dot_executable, "-Tjson", dot_path],
+ capture_output=True,
+ check=False
+ )
+ if result.returncode != 0 or not result.stdout:
+ return # dot unavailable or failed
+
+ enriched = json.loads(result.stdout.decode("utf-8"))
+
+ # 3. Build a name -> coordinates mapping from the enriched JSON
+ # (dot -Tjson uses `name` to identify nodes/clusters)
+ name_to_coords = {}
+ for obj in enriched.get("objects", []):
+ name = obj.get("name")
+ if not name:
+ continue
+ coords = {}
+ if "pos" in obj:
+ coords["pos"] = obj["pos"]
+ if "bb" in obj:
+ coords["bb"] = obj["bb"]
+ if "lp" in obj:
+ coords["lp"] = obj["lp"]
+ if "lwidth" in obj:
+ coords["lwidth"] = obj["lwidth"]
+ if "lheight" in obj:
+ coords["lheight"] = obj["lheight"]
+ if coords:
+ name_to_coords[name] = coords
+
+ # Global graph bounding box
+ if "bb" in enriched:
+ original["bb"] = enriched["bb"]
+
+ # 4. Inject coordinates into the original dot_json (matched by name)
+ for obj in original.get("objects", []):
+ name = obj.get("name")
+ if name and name in name_to_coords:
+ obj.update(name_to_coords[name])
+
+ # Edge coordinates: matched by (tail_name, head_name)
+ # Build a gvid -> name index for the original nodes
+ gvid_to_name = {o["_gvid"]: o.get("name", "") for o in original.get("objects", [])}
+ # Index for the enriched JSON
+ enriched_edge_map = {}
+ for e in enriched.get("edges", []):
+ tail_gvid = e.get("tail")
+ head_gvid = e.get("head")
+ enriched_objs = enriched.get("objects", [])
+ tail_name = enriched_objs[tail_gvid]["name"] if tail_gvid is not None and tail_gvid < len(enriched_objs) else None
+ head_name = enriched_objs[head_gvid]["name"] if head_gvid is not None and head_gvid < len(enriched_objs) else None
+ if tail_name and head_name and "pos" in e:
+ enriched_edge_map[(tail_name, head_name)] = e["pos"]
+
+ for edge in original.get("edges", []):
+ tail_name = gvid_to_name.get(edge.get("tail"))
+ head_name = gvid_to_name.get(edge.get("head"))
+ if tail_name and head_name:
+ pos = enriched_edge_map.get((tail_name, head_name))
+ if pos:
+ edge["pos"] = pos
+
+ # 5. Rewrite the enriched file
+ with open(dot_json_path, "w", encoding="utf-8") as f:
+ json.dump(original, f)
+
+ except Exception:
+ pass # On error, the file is left without coordinates
\ No newline at end of file
diff --git a/webapp/frontend/public/interactive_viewer/index.html b/webapp/frontend/public/interactive_viewer/index.html
index b0ac461..d66a4e5 100644
--- a/webapp/frontend/public/interactive_viewer/index.html
+++ b/webapp/frontend/public/interactive_viewer/index.html
@@ -76,6 +76,10 @@
+