Merge pull request #78 from Sandor59100/feature/interactive-viewer-improvements

Feature/interactive viewer improvements
This commit is contained in:
Philippe Merle
2026-07-06 15:05:08 +02:00
committed by GitHub
15 changed files with 715 additions and 156 deletions
+34 -14
View File
@@ -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,
+41 -22
View File
@@ -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}",
+27 -8
View File
@@ -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),
+40 -17
View File
@@ -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}",
+167 -1
View File
@@ -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;
}