mirror of
https://github.com/philippemerle/KubeDiagrams.git
synced 2026-08-16 14:16:14 +00:00
Merge pull request #78 from Sandor59100/feature/interactive-viewer-improvements
Feature/interactive viewer improvements
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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")
|
||||
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 "<img" in str(obj["label"]):
|
||||
obj["label"] = re.sub(
|
||||
r'src="([^"]*resources/[^"]+)"',
|
||||
lambda m: f'src="{_local_path_to_github_url(m.group(1))}"',
|
||||
obj["label"],
|
||||
)
|
||||
|
||||
with open(dot_json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def enrich_dot_json_with_positions(dot_json_path: str) -> 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
|
||||
@@ -76,6 +76,10 @@
|
||||
<button id="saveJPG">JPG</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters-container" id="filtersContainer">
|
||||
<span class="filters-label">Filters:</span>
|
||||
<div id="categoryFilters"></div>
|
||||
</div>
|
||||
<div id="paper"></div>
|
||||
<pre id="tooltip"></pre>
|
||||
</body>
|
||||
|
||||
@@ -14,14 +14,32 @@ function getDefaultGlobalNodeStyleFromNodeValues(node) {
|
||||
}
|
||||
|
||||
function getDefaultClusterStyleFromClusterValues(cluster) {
|
||||
let style = {
|
||||
'border-style': cluster.data.bs,
|
||||
'border-color': cluster.data.bc,
|
||||
'background-color': cluster.data.bgcolor,
|
||||
};
|
||||
|
||||
if (cluster.data.image && cluster.data.image.trim() !== '') {
|
||||
style['background-image'] = cluster.data.image;
|
||||
style['background-fit'] = 'none';
|
||||
style['background-width'] = '40px';
|
||||
style['background-height'] = '40px';
|
||||
style['background-position-x'] = '50%';
|
||||
style['background-position-y'] = '20px';
|
||||
style['background-opacity'] = 1;
|
||||
style['background-clip'] = 'node';
|
||||
style['text-valign'] = 'top';
|
||||
style['text-halign'] = 'center';
|
||||
style['text-margin-y'] = 18;
|
||||
style['padding'] = '45px';
|
||||
style['padding-top'] = '65px';
|
||||
}
|
||||
|
||||
return {
|
||||
selector : ".clusterStyle" + clusterStyleList.length,
|
||||
style : {
|
||||
'border-style': cluster.data.bs,
|
||||
'border-color': cluster.data.bc,
|
||||
'background-color': cluster.data.bgcolor,
|
||||
}
|
||||
};
|
||||
selector : ".clusterStyle" + clusterStyleList.length,
|
||||
style : style
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultEdgeStyleFromEdgeValues(edge) {
|
||||
@@ -51,6 +69,7 @@ const clusterClosedStyle = {
|
||||
|
||||
const clusterOpenStyle = {
|
||||
'text-valign': 'top',
|
||||
'text-halign': 'center',
|
||||
'text-wrap': 'none',
|
||||
'text-margin-y': 15,
|
||||
'padding': '15px',
|
||||
|
||||
@@ -21,7 +21,8 @@ const itemOpenClose = {
|
||||
function openCluster(cluster) {
|
||||
cluster.children().style('display', 'element');
|
||||
cluster.data('isClose', false);
|
||||
cluster.style(clusterOpenStyle);
|
||||
const image = cluster.data('image');
|
||||
cluster.style({ ...clusterOpenStyle, 'background-image': image || 'none' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,5 +32,6 @@ function openCluster(cluster) {
|
||||
function closeCluster(cluster) {
|
||||
cluster.children().style('display', 'none');
|
||||
cluster.data('isClose', true);
|
||||
cluster.style(clusterClosedStyle);
|
||||
}
|
||||
const image = cluster.data('image');
|
||||
cluster.style({ ...clusterClosedStyle, 'background-image': image || 'none' });
|
||||
}
|
||||
|
||||
@@ -1,16 +1,158 @@
|
||||
cytoscape.use(cytoscapeDagre);
|
||||
cytoscape.use(cytoscapeKlay);
|
||||
|
||||
let cy;
|
||||
let cy;
|
||||
let currentLayout;
|
||||
|
||||
const RESOURCE_CATEGORIES = {
|
||||
'Workloads': ['Pod', 'Deployment', 'ReplicaSet', 'StatefulSet', 'DaemonSet', 'Job', 'CronJob', 'ReplicationController', 'PodTemplate'],
|
||||
'Networking': ['Service', 'Ingress', 'IngressClass', 'NetworkPolicy', 'Endpoints', 'EndpointSlice', 'NetworkAttachmentDefinition'],
|
||||
'Storage': ['PersistentVolumeClaim', 'PersistentVolume', 'StorageClass', 'CSIDriver', 'CSINode', 'CSIStorageCapacity', 'VolumeAttachment'],
|
||||
'Configuration': ['ConfigMap', 'Secret'],
|
||||
'Access Control': ['ServiceAccount', 'Role', 'RoleBinding', 'ClusterRole', 'ClusterRoleBinding', 'PodSecurityPolicy', 'User', 'Group'],
|
||||
'Cluster & Ops': ['Node', 'Namespace', 'Event', 'HorizontalPodAutoscaler', 'VerticalPodAutoscaler', 'LimitRange', 'ResourceQuota', 'PodDisruptionBudget', 'PriorityClass', 'RuntimeClass', 'Lease'],
|
||||
'Extensions': ['CustomResourceDefinition', 'APIService', 'MutatingWebhookConfiguration', 'ValidatingWebhookConfiguration']
|
||||
};
|
||||
|
||||
function closeAllFilterPanels(except) {
|
||||
document.querySelectorAll('.filter-dropdown.open').forEach(dropdown => {
|
||||
if (dropdown !== except) dropdown.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
function renderFilters() {
|
||||
const container = document.getElementById('categoryFilters');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const [category, kinds] of Object.entries(RESOURCE_CATEGORIES)) {
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'filter-dropdown';
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'filter-dropdown-toggle';
|
||||
toggleBtn.textContent = category + ' ▾';
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const wasOpen = dropdown.classList.contains('open');
|
||||
closeAllFilterPanels();
|
||||
dropdown.classList.toggle('open', !wasOpen);
|
||||
});
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'filter-dropdown-panel';
|
||||
panel.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
const catLabel = document.createElement('label');
|
||||
catLabel.className = 'filter-category-label';
|
||||
|
||||
const catCheck = document.createElement('input');
|
||||
catCheck.type = 'checkbox';
|
||||
catCheck.checked = true;
|
||||
catCheck.className = 'category-checkbox';
|
||||
catCheck.value = category;
|
||||
|
||||
catLabel.appendChild(catCheck);
|
||||
catLabel.appendChild(document.createTextNode(' ' + category));
|
||||
panel.appendChild(catLabel);
|
||||
|
||||
kinds.forEach(kind => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'filter-kind-label';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = true;
|
||||
cb.value = kind.toLowerCase();
|
||||
cb.className = `kind-checkbox kind-${kind.toLowerCase()}`;
|
||||
cb.dataset.category = category;
|
||||
|
||||
label.appendChild(cb);
|
||||
label.appendChild(document.createTextNode(' ' + kind));
|
||||
panel.appendChild(label);
|
||||
|
||||
cb.addEventListener('change', () => {
|
||||
// If any child is unchecked, uncheck the parent. If all are checked, check it.
|
||||
const allChecked = Array.from(panel.querySelectorAll('.kind-checkbox')).every(c => c.checked);
|
||||
catCheck.checked = allChecked;
|
||||
updateCategoryFilters();
|
||||
updateDropdownToggleState(dropdown, toggleBtn, category, panel);
|
||||
});
|
||||
});
|
||||
|
||||
catCheck.addEventListener('change', (e) => {
|
||||
const isChecked = e.target.checked;
|
||||
panel.querySelectorAll('.kind-checkbox').forEach(cb => {
|
||||
cb.checked = isChecked;
|
||||
});
|
||||
updateCategoryFilters();
|
||||
updateDropdownToggleState(dropdown, toggleBtn, category, panel);
|
||||
});
|
||||
|
||||
dropdown.appendChild(toggleBtn);
|
||||
dropdown.appendChild(panel);
|
||||
container.appendChild(dropdown);
|
||||
}
|
||||
|
||||
document.addEventListener('click', () => closeAllFilterPanels());
|
||||
}
|
||||
|
||||
function updateDropdownToggleState(dropdown, toggleBtn, category, panel) {
|
||||
const checkboxes = panel.querySelectorAll('.kind-checkbox');
|
||||
const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
|
||||
dropdown.classList.toggle('filter-dropdown-partial', checkedCount > 0 && checkedCount < checkboxes.length);
|
||||
dropdown.classList.toggle('filter-dropdown-empty', checkedCount === 0);
|
||||
}
|
||||
|
||||
function updateCategoryFilters() {
|
||||
if (!cy) return;
|
||||
const kindCheckboxes = document.querySelectorAll('#categoryFilters .kind-checkbox');
|
||||
const visibleKinds = new Set();
|
||||
kindCheckboxes.forEach(cb => {
|
||||
if (cb.checked) visibleKinds.add(cb.value);
|
||||
});
|
||||
|
||||
cy.batch(() => {
|
||||
cy.nodes().forEach(node => {
|
||||
if (node.data('group') === 'cluster') return;
|
||||
const kind = (node.data('kind') || '').toLowerCase();
|
||||
|
||||
let isKnown = false;
|
||||
for (const kinds of Object.values(RESOURCE_CATEGORIES)) {
|
||||
if (kinds.some(k => k.toLowerCase() === kind)) {
|
||||
isKnown = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isKnown) {
|
||||
if (visibleKinds.has(kind)) {
|
||||
node.style('display', 'element');
|
||||
} else {
|
||||
node.style('display', 'none');
|
||||
}
|
||||
} else {
|
||||
node.style('display', 'element');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Construit la liste d'éléments Cytoscape à partir d'un objet DOT_JSON
|
||||
function buildElementsFromDotJson(json) {
|
||||
const elements = [];
|
||||
const nodes = json.objects || json.nodes || [];
|
||||
const nodes = json.objects || json.nodes || [];
|
||||
const edges = json.edges || [];
|
||||
addNodesElementsParsedFromNodesJson(elements, nodes);
|
||||
|
||||
const sortedNodes = [...nodes].sort((a, b) => {
|
||||
const aIsCluster = a.nodes || a.subgraphs;
|
||||
const bIsCluster = b.nodes || b.subgraphs;
|
||||
if (aIsCluster && !bIsCluster) return -1;
|
||||
if (!aIsCluster && bIsCluster) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
addNodesElementsParsedFromNodesJson(elements, sortedNodes);
|
||||
addNodesEdgesParsedFromEdgesJson(elements, edges);
|
||||
return elements;
|
||||
}
|
||||
@@ -30,14 +172,15 @@ window.renderFromDotJson = function (json) {
|
||||
* an event listener on the file input button to load the cytoscape graph.
|
||||
*/
|
||||
function setUp() {
|
||||
renderFilters();
|
||||
cy = getCyGraph();
|
||||
currentLayout = layoutList[0];
|
||||
createLayoutSelectorButton();
|
||||
document.getElementById("savePNG").addEventListener("click", () => { saveFile("png")});
|
||||
document.getElementById("saveJPG").addEventListener("click", () => { saveFile("jpg")});
|
||||
//document.getElementById("saveSVG").addEventListener("click", () => { saveFile("svg")});//
|
||||
|
||||
document.getElementById('fileInput').addEventListener('change', readFileAndloadCytoscapeGraph);
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const data = e && e.data;
|
||||
if (!data || data.type !== 'KD_LOAD_DOT_JSON' || !data.payload) return;
|
||||
@@ -133,9 +276,9 @@ function readFileAndloadCytoscapeGraph(event) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove precedent elements before to add the new elements then create tool tip and context menus for the
|
||||
* cytoscape instance.
|
||||
* @param {*} elements
|
||||
* Remove precedent elements before to add the new elements then create tool tip and context menus for the
|
||||
* cytoscape instance.
|
||||
* @param {*} elements
|
||||
*/
|
||||
function load_cytoscape(elements) {
|
||||
cy.nodes().remove();
|
||||
@@ -143,12 +286,13 @@ function load_cytoscape(elements) {
|
||||
createTooltip("node");
|
||||
createTooltip("edge");
|
||||
createAndGetContextMenu(cy);
|
||||
updateCategoryFilters();
|
||||
cy.layout(currentLayout).run();
|
||||
}
|
||||
|
||||
function load_layout(layout) {
|
||||
currentLayout = layout;
|
||||
cy.layout(currentLayout).run();
|
||||
cy.layout(layout).run();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,12 +305,50 @@ function addNodesElementsParsedFromNodesJson(elements, nodesJson) {
|
||||
let parent = {};
|
||||
|
||||
for (let i in nodesJson) {
|
||||
let tooltip = nodesJson[i].tooltip ?? '';
|
||||
let kindMatch = tooltip.match(/kind:\s*([A-Za-z0-9_]+)/i);
|
||||
let kind = kindMatch ? kindMatch[1] : '';
|
||||
|
||||
let image = nodesJson[i].image ?? '';
|
||||
let label = nodesJson[i].label ?? '';
|
||||
|
||||
if (!image && typeof label === 'string' && label.includes('<img')) {
|
||||
let imgMatch = label.match(/<img[^>]+src="([^"]+)"/);
|
||||
if (imgMatch) {
|
||||
image = imgMatch[1];
|
||||
}
|
||||
|
||||
// Try to extract only the text part from the table structure for the label
|
||||
let textMatch = label.match(/<tr><td>([^<]+)<\/td><\/tr><\/table>/) || label.match(/<td>([^<]+)<\/td>/g);
|
||||
if (textMatch) {
|
||||
if (textMatch.length > 0 && Array.isArray(textMatch) && textMatch[0].startsWith('<td>')) {
|
||||
// It's the global match array
|
||||
let lastMatch = textMatch[textMatch.length - 1];
|
||||
let rawText = lastMatch.replace(/<\/?td>/g, '');
|
||||
label = (nodesJson[i].tooltip && nodesJson[i].tooltip.includes(rawText))
|
||||
? nodesJson[i].tooltip.split('\n')[0] // Use first line of tooltip e.g. "Namespace: default"
|
||||
: rawText;
|
||||
} else if (textMatch[1]) {
|
||||
label = (nodesJson[i].tooltip && nodesJson[i].tooltip.includes(textMatch[1]))
|
||||
? nodesJson[i].tooltip.split('\n')[0]
|
||||
: textMatch[1];
|
||||
}
|
||||
} else {
|
||||
// Fallback to tooltip if label is completely unparsable HTML
|
||||
if (label.includes('<') && label.includes('>') && tooltip) {
|
||||
label = tooltip.split('\n')[0];
|
||||
}
|
||||
}
|
||||
} else if (label.includes('<') && label.includes('>')) {
|
||||
label = tooltip.split('\n')[0];
|
||||
}
|
||||
|
||||
let node = {
|
||||
data: {
|
||||
id: nodesJson[i]._gvid,
|
||||
group: (nodesJson[i].nodes) ? 'cluster' : 'node',
|
||||
isClose : false,
|
||||
label: (nodesJson[i].label.includes('<') && nodesJson[i].label.includes('>')) ? nodesJson[i].tooltip : nodesJson[i].label,
|
||||
label: label,
|
||||
bs: getCorrespondingBorderStyle(nodesJson[i].style),
|
||||
bgcolor: getCorrespondingColor(nodesJson[i].bgcolor ?? 'blue'),
|
||||
bc: nodesJson[i].pencolor ?? 'gray',
|
||||
@@ -174,8 +356,9 @@ function addNodesElementsParsedFromNodesJson(elements, nodesJson) {
|
||||
fontsize: nodesJson[i].fontsize ?? '',
|
||||
fontfamily: nodesJson[i].fontname ?? '',
|
||||
fontcolor: nodesJson[i].fontcolor ?? '',
|
||||
image: (nodesJson[i].image) ? nodesJson[i].image : '',
|
||||
tooltip: nodesJson[i].tooltip ?? ''
|
||||
image: image,
|
||||
tooltip: tooltip,
|
||||
kind: kind
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,36 +464,14 @@ function createTooltip(elementType) {
|
||||
function createLayoutSelectorButton() {
|
||||
const layoutButtons = document.getElementById("layoutButtons");
|
||||
for (let layout of layoutList) {
|
||||
let button = document.createElement("button")
|
||||
let button = document.createElement("button");
|
||||
button.id = layout.name;
|
||||
button.textContent = layout.name;
|
||||
button.addEventListener("click", () => {load_layout(layout)} );
|
||||
button.textContent = layout.displayName || layout.name;
|
||||
button.addEventListener("click", () => { load_layout(layout); });
|
||||
layoutButtons.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Modifier ici pour récupérer les données via un blob
|
||||
// function saveFile(format) {
|
||||
// const data = getFileAs(format);
|
||||
|
||||
// if (format === 'svg') {
|
||||
// console.log(data);
|
||||
// const blob = new Blob([data], { type: 'image/svg+xml' });
|
||||
// const url = URL.createObjectURL(blob);
|
||||
// const a = document.createElement('a');
|
||||
// a.href = url;
|
||||
// a.download = 'graph.svg';
|
||||
// a.click();
|
||||
// setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
// return;
|
||||
// }
|
||||
// const a = document.createElement('a');
|
||||
// a.href = data;
|
||||
// a.download = 'graph.' + format;
|
||||
// a.click();
|
||||
// }
|
||||
|
||||
/**
|
||||
* Save file in png or jpg format.
|
||||
* @param {*} format
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#paper {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
margin: auto;
|
||||
border: solid;
|
||||
@@ -13,6 +22,78 @@
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.filters-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
.filters-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
#categoryFilters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
.filter-dropdown-toggle {
|
||||
cursor: pointer;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #f9f9f9;
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.filter-dropdown.open .filter-dropdown-toggle {
|
||||
background: #e3edff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.filter-dropdown-partial .filter-dropdown-toggle {
|
||||
border-color: #3b82f6;
|
||||
border-style: dashed;
|
||||
}
|
||||
.filter-dropdown-empty .filter-dropdown-toggle {
|
||||
color: #999;
|
||||
}
|
||||
.filter-dropdown-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 8px 12px;
|
||||
min-width: 180px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.filter-dropdown.open .filter-dropdown-panel {
|
||||
display: block;
|
||||
}
|
||||
.filter-category-label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-kind-label {
|
||||
display: block;
|
||||
margin-left: 4px;
|
||||
margin-bottom: 2px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
@@ -21,4 +102,8 @@
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { generateClusterDiagram } from '../../../services/diagramApi.js';
|
||||
import { useViewerSync } from '../../../hooks/useViewerSync.js';
|
||||
import { useDiagramGeneration } from '../../../hooks/useDiagramGeneration.js';
|
||||
import { useClusterData } from '../../../hooks/useClusterData.js';
|
||||
import { useScrollToOutput } from '../../../hooks/useScrollToOutput.js';
|
||||
import ClusterInput from './ClusterInput.jsx';
|
||||
import ClusterOutput from './ClusterOutput.jsx';
|
||||
import CommandDetails from '../../common/CommandDetails.jsx';
|
||||
@@ -35,6 +36,7 @@ function ClusterTab({ historyContext }) {
|
||||
setErrorMessage,
|
||||
isSubmitting,
|
||||
viewerKey,
|
||||
progressStep,
|
||||
handleSubmit: generateDiagram,
|
||||
resetOutput,
|
||||
} = useDiagramGeneration({
|
||||
@@ -95,6 +97,7 @@ function ClusterTab({ historyContext }) {
|
||||
});
|
||||
|
||||
// Auto-scroll to output when diagram is ready
|
||||
const outputRef = useScrollToOutput(progressStep);
|
||||
|
||||
// History restoration
|
||||
const hasRestoredRef = useRef(false);
|
||||
@@ -198,7 +201,7 @@ function ClusterTab({ historyContext }) {
|
||||
</div>
|
||||
|
||||
{/* Output Section */}
|
||||
<div className="lg:w-3/4">
|
||||
<div className="lg:w-3/4" ref={outputRef}>
|
||||
<ClusterOutput
|
||||
diagram={diagram}
|
||||
mimeType={mimeType}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 { useScrollToOutput } from '../../../hooks/useScrollToOutput.js';
|
||||
import HelmFileInput from './HelmFileInput.jsx';
|
||||
import HelmFileOutput from './HelmFileOutput.jsx';
|
||||
import ProgressBar from '../../common/ProgressBar.jsx';
|
||||
@@ -53,6 +54,9 @@ function HelmFileTab({ historyContext }) {
|
||||
// dot_json viewer sync
|
||||
const { viewerRef, handleViewerLoad } = useViewerSync({ diagram, outputFormat });
|
||||
|
||||
// Auto-scroll to output when diagram is ready
|
||||
const outputRef = useScrollToOutput(progressStep);
|
||||
|
||||
// File upload handler
|
||||
const { createFileInputHandler } = useFileUpload();
|
||||
|
||||
@@ -152,21 +156,23 @@ function HelmFileTab({ historyContext }) {
|
||||
isVisible={progressStep !== 'idle'}
|
||||
/> */}
|
||||
|
||||
<HelmFileOutput
|
||||
errorMessage={errorMessage}
|
||||
diagram={diagram}
|
||||
outputFormat={outputFormat}
|
||||
mimeType={mimeType}
|
||||
filename={filename}
|
||||
command={command}
|
||||
stdout={stdout}
|
||||
stderr={stderr}
|
||||
message={message}
|
||||
viewerKey={viewerKey}
|
||||
viewerRef={viewerRef}
|
||||
onViewerLoad={handleViewerLoad}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<div ref={outputRef}>
|
||||
<HelmFileOutput
|
||||
errorMessage={errorMessage}
|
||||
diagram={diagram}
|
||||
outputFormat={outputFormat}
|
||||
mimeType={mimeType}
|
||||
filename={filename}
|
||||
command={command}
|
||||
stdout={stdout}
|
||||
stderr={stderr}
|
||||
message={message}
|
||||
viewerKey={viewerKey}
|
||||
viewerRef={viewerRef}
|
||||
onViewerLoad={handleViewerLoad}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 { useScrollToOutput } from '../../../hooks/useScrollToOutput.js';
|
||||
import HelmInput from './HelmInput.jsx';
|
||||
import HelmOutput from './HelmOutput.jsx';
|
||||
// import ProgressBar from '../../common/ProgressBar.jsx'; // Temporarily disabled
|
||||
@@ -52,6 +53,9 @@ function HelmTab({ historyContext }) {
|
||||
// dot_json viewer sync
|
||||
const { viewerRef, handleViewerLoad } = useViewerSync({ diagram, outputFormat });
|
||||
|
||||
// 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);
|
||||
@@ -175,21 +179,23 @@ function HelmTab({ historyContext }) {
|
||||
isVisible={progressStep !== 'idle'}
|
||||
/> */}
|
||||
|
||||
<HelmOutput
|
||||
backendError={backendError}
|
||||
diagram={diagram}
|
||||
outputFormat={outputFormat}
|
||||
mimeType={mimeType}
|
||||
filename={filename}
|
||||
command={command}
|
||||
stdout={stdout}
|
||||
stderr={stderr}
|
||||
message={message}
|
||||
viewerKey={viewerKey}
|
||||
viewerRef={viewerRef}
|
||||
onViewerLoad={handleViewerLoad}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<div ref={outputRef}>
|
||||
<HelmOutput
|
||||
backendError={backendError}
|
||||
diagram={diagram}
|
||||
outputFormat={outputFormat}
|
||||
mimeType={mimeType}
|
||||
filename={filename}
|
||||
command={command}
|
||||
stdout={stdout}
|
||||
stderr={stderr}
|
||||
message={message}
|
||||
viewerKey={viewerKey}
|
||||
viewerRef={viewerRef}
|
||||
onViewerLoad={handleViewerLoad}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 { useScrollToOutput } from '../../../hooks/useScrollToOutput.js';
|
||||
import ManifestInput from './ManifestInput.jsx';
|
||||
import ManifestOutput from './ManifestOutput.jsx';
|
||||
// import ProgressBar from '../../common/ProgressBar.jsx'; // Temporarily disabled
|
||||
@@ -56,6 +57,9 @@ function ManifestTab({ historyContext }) {
|
||||
// dot_json viewer sync
|
||||
const { viewerRef, handleViewerLoad } = useViewerSync({ diagram, outputFormat });
|
||||
|
||||
// Auto-scroll to output when diagram is ready
|
||||
const outputRef = useScrollToOutput(progressStep);
|
||||
|
||||
// File upload handler
|
||||
const { createFileInputHandler } = useFileUpload();
|
||||
|
||||
@@ -155,21 +159,23 @@ function ManifestTab({ historyContext }) {
|
||||
isVisible={progressStep !== 'idle'}
|
||||
/> */}
|
||||
|
||||
<ManifestOutput
|
||||
errorMessage={errorMessage}
|
||||
diagram={diagram}
|
||||
outputFormat={outputFormat}
|
||||
mimeType={mimeType}
|
||||
filename={filename}
|
||||
command={command}
|
||||
stdout={stdout}
|
||||
stderr={stderr}
|
||||
message={message}
|
||||
viewerKey={viewerKey}
|
||||
viewerRef={viewerRef}
|
||||
onViewerLoad={handleViewerLoad}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<div ref={outputRef}>
|
||||
<ManifestOutput
|
||||
errorMessage={errorMessage}
|
||||
diagram={diagram}
|
||||
outputFormat={outputFormat}
|
||||
mimeType={mimeType}
|
||||
filename={filename}
|
||||
command={command}
|
||||
stdout={stdout}
|
||||
stderr={stderr}
|
||||
message={message}
|
||||
viewerKey={viewerKey}
|
||||
viewerRef={viewerRef}
|
||||
onViewerLoad={handleViewerLoad}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Scrolls automatically to the output section when the diagram generation
|
||||
* completes (success or error), so the user doesn't have to scroll manually.
|
||||
*
|
||||
* @param {string} progressStep - Current step from useDiagramGeneration
|
||||
* @returns {React.RefObject} ref to attach to the output section wrapper
|
||||
*/
|
||||
export function useScrollToOutput(progressStep) {
|
||||
const outputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ((progressStep === 'completed' || progressStep === 'error') && outputRef.current) {
|
||||
outputRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, [progressStep]);
|
||||
|
||||
return outputRef;
|
||||
}
|
||||
Reference in New Issue
Block a user