From dab2c220762547c695ad1d62bd75ee33316cb67d Mon Sep 17 00:00:00 2001 From: Sadallah Date: Sun, 5 Jul 2026 14:05:42 +0200 Subject: [PATCH 1/2] feat(webapp): add interactive viewer enrichment - Enrich dot_json generation (icon URLs, node positions) in cluster, helm, helmfile, and manifest backend services - Add resource category filters and cluster icon rendering to the interactive viewer - Auto-scroll to output on generation in Cluster/Helm/HelmFile/Manifest tabs --- webapp/backend/services/clusterService.py | 48 ++-- webapp/backend/services/helmService.py | 63 ++++-- webapp/backend/services/helmfileService.py | 35 ++- webapp/backend/services/manifestService.py | 57 +++-- webapp/backend/services/utils.py | 168 +++++++++++++- .../public/interactive_viewer/index.html | 4 + .../interactive_viewer/script/defaultStyle.js | 33 ++- .../script/itemAndFunctionMenus.js | 8 +- .../public/interactive_viewer/script/main.js | 206 +++++++++++++++--- .../public/interactive_viewer/style.css | 6 +- .../src/components/tabs/ClusterTab/index.jsx | 5 +- .../src/components/tabs/HelmFileTab/index.jsx | 36 +-- .../src/components/tabs/HelmTab/index.jsx | 36 +-- .../src/components/tabs/ManifestTab/index.jsx | 36 +-- .../frontend/src/hooks/useScrollToOutput.js | 20 ++ 15 files changed, 606 insertions(+), 155 deletions(-) create mode 100644 webapp/frontend/src/hooks/useScrollToOutput.js 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..54cc617 100644 --- a/webapp/frontend/public/interactive_viewer/index.html +++ b/webapp/frontend/public/interactive_viewer/index.html @@ -76,6 +76,10 @@ +
+
Resource Filters (Check to show)
+
+

     
diff --git a/webapp/frontend/public/interactive_viewer/script/defaultStyle.js b/webapp/frontend/public/interactive_viewer/script/defaultStyle.js
index d20296b..bc7ec7a 100644
--- a/webapp/frontend/public/interactive_viewer/script/defaultStyle.js
+++ b/webapp/frontend/public/interactive_viewer/script/defaultStyle.js
@@ -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',
diff --git a/webapp/frontend/public/interactive_viewer/script/itemAndFunctionMenus.js b/webapp/frontend/public/interactive_viewer/script/itemAndFunctionMenus.js
index 09d1b0d..faa3b79 100644
--- a/webapp/frontend/public/interactive_viewer/script/itemAndFunctionMenus.js
+++ b/webapp/frontend/public/interactive_viewer/script/itemAndFunctionMenus.js
@@ -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);
-}
\ No newline at end of file
+    const image = cluster.data('image');
+    cluster.style({ ...clusterClosedStyle, 'background-image': image || 'none' });
+}
diff --git a/webapp/frontend/public/interactive_viewer/script/main.js b/webapp/frontend/public/interactive_viewer/script/main.js
index 4b34297..7f4226b 100644
--- a/webapp/frontend/public/interactive_viewer/script/main.js
+++ b/webapp/frontend/public/interactive_viewer/script/main.js
@@ -1,16 +1,131 @@
 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 renderFilters() {
+    const container = document.getElementById('categoryFilters');
+    if (!container) return;
+    container.innerHTML = '';
+    
+    for (const [category, kinds] of Object.entries(RESOURCE_CATEGORIES)) {
+        const catDiv = document.createElement('div');
+        catDiv.style.display = 'flex';
+        catDiv.style.flexDirection = 'column';
+        catDiv.style.minWidth = '140px';
+        
+        const catLabel = document.createElement('label');
+        catLabel.style.fontWeight = 'bold';
+        catLabel.style.marginBottom = '6px';
+        catLabel.style.borderBottom = '1px solid #ddd';
+        catLabel.style.cursor = 'pointer';
+        
+        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));
+        catDiv.appendChild(catLabel);
+        
+        kinds.forEach(kind => {
+            const label = document.createElement('label');
+            label.style.marginLeft = '12px';
+            label.style.cursor = 'pointer';
+            label.style.marginBottom = '2px';
+            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));
+            catDiv.appendChild(label);
+            
+            cb.addEventListener('change', () => {
+                // If any child is unchecked, uncheck the parent. If all are checked, check it.
+                const allChecked = Array.from(catDiv.querySelectorAll('.kind-checkbox')).every(c => c.checked);
+                catCheck.checked = allChecked;
+                updateCategoryFilters();
+            });
+        });
+        
+        catCheck.addEventListener('change', (e) => {
+            const isChecked = e.target.checked;
+            catDiv.querySelectorAll('.kind-checkbox').forEach(cb => {
+                cb.checked = isChecked;
+            });
+            updateCategoryFilters();
+        });
+        
+        container.appendChild(catDiv);
+    }
+}
+
+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 +145,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 +249,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 +259,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 +278,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(']+src="([^"]+)"/);
+            if (imgMatch) {
+                image = imgMatch[1];
+            }
+
+            // Try to extract only the text part from the table structure for the label
+            let textMatch = label.match(/([^<]+)<\/td><\/tr><\/table>/) || label.match(/([^<]+)<\/td>/g);
+            if (textMatch) {
+                if (textMatch.length > 0 && Array.isArray(textMatch) && textMatch[0].startsWith('')) {
+                    // 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 +329,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 +437,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 
diff --git a/webapp/frontend/public/interactive_viewer/style.css b/webapp/frontend/public/interactive_viewer/style.css
index bf62cfa..6c2e961 100644
--- a/webapp/frontend/public/interactive_viewer/style.css
+++ b/webapp/frontend/public/interactive_viewer/style.css
@@ -21,4 +21,8 @@
     padding: 5px 10px;
     border-radius: 4px;
     font-size: 15px;
-}
\ No newline at end of file
+}
+input[type="checkbox"] {
+    accent-color: #3b82f6;
+}
+
diff --git a/webapp/frontend/src/components/tabs/ClusterTab/index.jsx b/webapp/frontend/src/components/tabs/ClusterTab/index.jsx
index dbd359e..088f5d5 100644
--- a/webapp/frontend/src/components/tabs/ClusterTab/index.jsx
+++ b/webapp/frontend/src/components/tabs/ClusterTab/index.jsx
@@ -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 }) {
         
 
         {/* Output Section  */}
-        
+
*/} - +
+ +
); } diff --git a/webapp/frontend/src/components/tabs/HelmTab/index.jsx b/webapp/frontend/src/components/tabs/HelmTab/index.jsx index d194f78..b777d19 100644 --- a/webapp/frontend/src/components/tabs/HelmTab/index.jsx +++ b/webapp/frontend/src/components/tabs/HelmTab/index.jsx @@ -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'} /> */} - +
+ +
); } diff --git a/webapp/frontend/src/components/tabs/ManifestTab/index.jsx b/webapp/frontend/src/components/tabs/ManifestTab/index.jsx index 6e3fd48..c83bdb9 100644 --- a/webapp/frontend/src/components/tabs/ManifestTab/index.jsx +++ b/webapp/frontend/src/components/tabs/ManifestTab/index.jsx @@ -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'} /> */} - +
+ +
); } diff --git a/webapp/frontend/src/hooks/useScrollToOutput.js b/webapp/frontend/src/hooks/useScrollToOutput.js new file mode 100644 index 0000000..e8194fb --- /dev/null +++ b/webapp/frontend/src/hooks/useScrollToOutput.js @@ -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; +} \ No newline at end of file From e72cbd68f8ca87900226c78b931a2998964093a9 Mon Sep 17 00:00:00 2001 From: Sadallah Date: Sun, 5 Jul 2026 15:10:23 +0200 Subject: [PATCH 2/2] feat(webapp): replace resource filter columns with toolbar dropdowns - Turn each resource category into a compact dropdown button in the toolbar instead of an always-expanded checkbox column - Show the filter panel as a floating overlay to not push down the canvas. --- .../public/interactive_viewer/index.html | 6 +- .../public/interactive_viewer/script/main.js | 77 +++++++++++------ .../public/interactive_viewer/style.css | 83 ++++++++++++++++++- 3 files changed, 137 insertions(+), 29 deletions(-) diff --git a/webapp/frontend/public/interactive_viewer/index.html b/webapp/frontend/public/interactive_viewer/index.html index 54cc617..d66a4e5 100644 --- a/webapp/frontend/public/interactive_viewer/index.html +++ b/webapp/frontend/public/interactive_viewer/index.html @@ -76,9 +76,9 @@ -
-
Resource Filters (Check to show)
-
+
+ Filters: +

diff --git a/webapp/frontend/public/interactive_viewer/script/main.js b/webapp/frontend/public/interactive_viewer/script/main.js
index 7f4226b..116d019 100644
--- a/webapp/frontend/public/interactive_viewer/script/main.js
+++ b/webapp/frontend/public/interactive_viewer/script/main.js
@@ -14,67 +14,94 @@ const RESOURCE_CATEGORIES = {
     '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 catDiv = document.createElement('div');
-        catDiv.style.display = 'flex';
-        catDiv.style.flexDirection = 'column';
-        catDiv.style.minWidth = '140px';
-        
+        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.style.fontWeight = 'bold';
-        catLabel.style.marginBottom = '6px';
-        catLabel.style.borderBottom = '1px solid #ddd';
-        catLabel.style.cursor = 'pointer';
-        
+        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));
-        catDiv.appendChild(catLabel);
-        
+        panel.appendChild(catLabel);
+
         kinds.forEach(kind => {
             const label = document.createElement('label');
-            label.style.marginLeft = '12px';
-            label.style.cursor = 'pointer';
-            label.style.marginBottom = '2px';
+            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));
-            catDiv.appendChild(label);
-            
+            panel.appendChild(label);
+
             cb.addEventListener('change', () => {
                 // If any child is unchecked, uncheck the parent. If all are checked, check it.
-                const allChecked = Array.from(catDiv.querySelectorAll('.kind-checkbox')).every(c => c.checked);
+                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;
-            catDiv.querySelectorAll('.kind-checkbox').forEach(cb => {
+            panel.querySelectorAll('.kind-checkbox').forEach(cb => {
                 cb.checked = isChecked;
             });
             updateCategoryFilters();
+            updateDropdownToggleState(dropdown, toggleBtn, category, panel);
         });
-        
-        container.appendChild(catDiv);
+
+        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() {
diff --git a/webapp/frontend/public/interactive_viewer/style.css b/webapp/frontend/public/interactive_viewer/style.css
index 6c2e961..cf93687 100644
--- a/webapp/frontend/public/interactive_viewer/style.css
+++ b/webapp/frontend/public/interactive_viewer/style.css
@@ -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;