Merge pull request #74 from Sandor59100/ClusterOption

feat(webapp): add cluster tab
This commit is contained in:
Philippe Merle
2026-06-11 22:52:56 +02:00
committed by GitHub
27 changed files with 1606 additions and 65 deletions
+23 -6
View File
@@ -4,9 +4,9 @@ Web Interface for generating Kubernetes diagrams from manifests, Helm charts, or
A modern web application for generating Kubernetes architecture diagrams from manifests, Helm charts, or Helmfile configurations using [KubeDiagrams](https://github.com/philippemerle/KubeDiagrams).
## Features
## Features
- **Multiple Input Types**: Support for Kubernetes manifests, Helm charts, and Helmfile configurations
- **Multiple Input Types**: Support for Kubernetes manifests, Helm charts, Helmfile configurations, and live cluster diagrams
- **Flexible Output Formats**: Generate diagrams in PNG, SVG, PDF, DOT, and interactive HTML
- **Interactive Viewer**: Explore diagrams with an interactive web viewer
- **Built-in Examples**: Pre-loaded examples for quick testing
@@ -75,11 +75,13 @@ webapp/
│ │ ├── manifest.py # Manifest diagram generation endpoints
│ │ ├── helm.py # Helm chart diagram endpoints
│ │ ├── helmfile.py # Helmfile diagram endpoints
│ │ ├── cluster.py # Cluster diagram endpoints
│ │ └── submit.py # Feedback submission endpoint
│ ├── services/ # Business logic layer
│ │ ├── manifestService.py # Manifest processing service
│ │ ├── helmService.py # Helm processing service
│ │ ├── helmfileService.py # Helmfile processing service
│ │ ├── clusterService.py # Cluster processing service
│ │ ├── file_manager.py # File operations manager
│ │ └── models.py # Data models
│ ├── utils/ # Utility modules
@@ -103,6 +105,7 @@ webapp/
│ │ │ ├── ManifestTab/ # Kubernetes manifest tab
│ │ │ ├── HelmTab/ # Helm chart tab
│ │ │ ├── HelmFileTab/ # Helmfile tab
│ │ │ ├── ClusterTab/ # Live cluster tab
│ │ │ └── InteractiveViewerTab/ # Interactive viewer
│ │ ├── examples/ # Example registry
│ │ ├── hooks/ # Custom React hooks
@@ -311,9 +314,22 @@ Generate diagrams from Helmfile configurations.
4. Click "Generate Diagram"
**Requirement**: `helmfile` must be installed on the server.
### 4. Interactive Viewer
5.
### 4. Cluster Tab
Generate diagrams from a live Kubernetes cluster.
**Steps**:
1. Select the "Cluster" tab
2. Optionally specify a Kubernetes context (leave empty to use current context)
3. Optionally specify a namespace (leave empty to diagram all namespaces)
4. Configure diagram options
5. Click "Generate Cluster Diagram"
**Requirements**:
- `kubectl-diagrams` must be installed on the server
- Server must have access to a Kubernetes cluster via kubeconfig
- Proper RBAC permissions to read cluster resources
**Note**: This uses the server's kubectl configuration, not the client's.
6.
### 5. Interactive Viewer
View diagrams in an interactive HTML viewer with zoom, pan, and search capabilities.
**Features**:
@@ -371,6 +387,7 @@ See `frontend/public/examples/README.md` for instructions on adding new examples
- `POST /api/manifest/generate` - Generate diagram from manifest
- `POST /api/helm/generate` - Generate diagram from Helm chart
- `POST /api/helmfile/generate` - Generate diagram from Helmfile
- `POST /api/cluster/generate` - Generate diagram from live cluster
- `POST /api/submit` - Submit feedback
---
@@ -458,5 +475,5 @@ For issues and questions, please [open an issue on the GitHub repository](https:
---
## 🙏 Acknowledgments
## Acknowledgments
- [Graphviz](https://graphviz.org/) - Graph visualization software
+7
View File
@@ -23,6 +23,13 @@ RUN apk update && apk add --no-cache \
musl-dev \
&& rm -rf /var/cache/apk/*
# Install kubectl (statically compiled Go binary — works on Alpine/musl)
RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') && \
KUBECTL_VERSION=$(curl -fsSL https://dl.k8s.io/release/stable.txt) && \
curl -fsSL -o /usr/local/bin/kubectl \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" && \
chmod +x /usr/local/bin/kubectl
# Copy Helm from first stage
COPY --from=helm /usr/bin/helm /usr/local/bin/helm
+3 -1
View File
@@ -7,6 +7,7 @@ from routes.manifest import manifest_bp
from routes.helm import helm_bp
from routes.helmfile import helmfile_bp
from routes.submit import submit_bp
from routes.cluster import cluster_bp
from utils.access_logger import log_request, get_real_ip, get_all_ip_headers
from time import time
@@ -60,7 +61,7 @@ def create_app():
# Logging Configuration
setup_logging()
# Middleware pour logger les requêtes
# Middleware to log requests
@app.before_request
def before_request():
"""Save request start time for performance logging."""
@@ -89,6 +90,7 @@ def create_app():
app.register_blueprint(helm_bp)
app.register_blueprint(helmfile_bp)
app.register_blueprint(submit_bp)
app.register_blueprint(cluster_bp)
return app
-1
View File
@@ -16,7 +16,6 @@ MarkupSafe==3.0.3
nodeenv==1.10.0
platformdirs==4.9.4
pre_commit==4.5.1
puremagic==2.0.2
pygraphviz==1.14
python-discovery==1.1.3
PyYAML==6.0.3
+122
View File
@@ -0,0 +1,122 @@
"""Routes for diagram generation from live Kubernetes cluster."""
from flask import Blueprint, request
from .utils import log_to_csv, compact_for_log
from services import (
generate_from_cluster,
get_namespaces,
get_resource_types,
get_current_context,
)
from utils import InputValidator, ResponseBuilder
cluster_bp = Blueprint('cluster', __name__)
@cluster_bp.route('/api/cluster/context', methods=['GET'])
def get_context():
"""Return the name of the currently active kubectl context."""
try:
context = get_current_context()
return ResponseBuilder.success({"context": context})
except RuntimeError as e:
return ResponseBuilder.error(str(e))
except Exception as e:
return ResponseBuilder.error(f"Unexpected error: {str(e)}")
@cluster_bp.route('/api/cluster/namespaces', methods=['GET'])
def list_namespaces():
"""Return the list of namespaces available in the connected Kubernetes cluster."""
try:
namespaces = get_namespaces()
return ResponseBuilder.success({
"namespaces": namespaces,
"count": len(namespaces)
})
except RuntimeError as e:
return ResponseBuilder.error(str(e))
except Exception as e:
return ResponseBuilder.error(f"Unexpected error: {str(e)}")
@cluster_bp.route('/api/cluster/resource-types', methods=['GET'])
def list_resource_types():
"""Return all resource types known by the cluster, tagged with namespace scope and common status."""
try:
resource_types = get_resource_types()
return ResponseBuilder.success({
"resourceTypes": resource_types,
"count": len(resource_types)
})
except RuntimeError as e:
return ResponseBuilder.error(str(e))
except Exception as e:
return ResponseBuilder.error(f"Unexpected error: {str(e)}")
@cluster_bp.route('/api/cluster/generate', methods=['POST'])
def generate_cluster_diagram():
"""Generate a diagram from live Kubernetes cluster resources using kubectl-diagrams."""
data = request.get_json()
namespace = data.get('namespace')
resource_types = data.get('resourceTypes', [])
all_namespaces = data.get('allNamespaces', False)
output_format = (data.get('outputFormat') or 'png').lower()
extra_args = data.get('extraArgs', '')
without_namespace = data.get('withoutNamespace', False)
# Log to CSV
client_ip = request.remote_addr
route = request.path
params = (
f"namespace={namespace};"
f"resourceTypes={','.join(resource_types)};"
f"allNamespaces={all_namespaces};"
f"format={output_format};"
f"extraArgs={compact_for_log(extra_args)};"
f"withoutNamespace={without_namespace}"
)
log_to_csv(client_ip, route, params)
# Validate resource types
if not resource_types:
return ResponseBuilder.validation_error("resourceTypes", "At least one resource type must be selected")
# Validate namespace if provided
if namespace and not InputValidator.validate_k8s_name(namespace):
return ResponseBuilder.validation_error("namespace", "Invalid namespace name")
# Output format validation
is_valid, error_msg = InputValidator.validate_output_format(output_format)
if not is_valid:
return ResponseBuilder.validation_error("outputFormat", error_msg)
# Extra arguments validation
is_valid, error_msg = InputValidator.validate_extra_args(extra_args)
if not is_valid:
return ResponseBuilder.validation_error("extraArgs", error_msg)
# Generate diagram using kubectl-diagrams
result = generate_from_cluster(
namespace=namespace,
resource_types=resource_types,
all_namespaces=all_namespaces,
output_format=output_format,
extra_args=extra_args,
without_namespace=without_namespace
)
if result.success:
return ResponseBuilder.success(result.to_dict())
else:
return ResponseBuilder.error(
result.error,
details={
"command": result.command,
"stdout": result.stdout,
"stderr": result.stderr
}
)
+2 -2
View File
@@ -23,13 +23,13 @@ def submit_feedback():
if note or comment:
try:
with open(Config.FEEDBACK_FILE, "a", encoding="utf-8") as f:
f.write(f"[{diagram_type.upper()}]\nNote: {note}\nCommentaire: {comment}\n\n")
f.write(f"[{diagram_type.upper()}]\nNote: {note}\nComment: {comment}\n\n")
return ResponseBuilder.success(
message="Feedback submitted successfully. Thank you!"
)
except Exception as e:
# Log l'erreur dans le CSV
# Log error to CSV
csv_logger = logging.getLogger(Config.LOGGER_NAME)
csv_logger.error(f"Error writing feedback: {e}")
return ResponseBuilder.error(
+12 -1
View File
@@ -4,11 +4,22 @@ from .file_manager import FileManager
from .manifestService import generate_from_manifest
from .helmService import generate_from_helm
from .helmfileService import generate_from_helmfile
from .clusterService import (
generate_from_cluster,
get_namespaces,
get_resource_types,
get_current_context,
)
__all__ = [
'DiagramResult',
'FileManager',
'generate_from_manifest',
'generate_from_helm',
'generate_from_helmfile'
'generate_from_helmfile',
'generate_from_cluster',
'get_namespaces',
'get_resource_types',
'get_current_context',
]
+257
View File
@@ -0,0 +1,257 @@
"""Service for generating diagrams from live Kubernetes cluster resources."""
import subprocess
import json
import os
import uuid
import tempfile
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
COMMON_RESOURCE_TYPES = frozenset({
'pods', 'services', 'deployments', 'replicasets', 'statefulsets',
'daemonsets', 'configmaps', 'secrets', 'ingresses',
'persistentvolumeclaims', 'persistentvolumes', 'nodes',
'namespaces', 'serviceaccounts', 'roles', 'rolebindings',
'clusterroles', 'clusterrolebindings', 'jobs', 'cronjobs',
'horizontalpodautoscalers', 'networkpolicies', 'storageclasses',
'runtimeclasses',
# events and endpoints excluded: they produce noisy intermediate resources
})
_KUBECTL_NOT_FOUND = (
"kubectl is not installed or not found in PATH. "
"Install it from https://kubernetes.io/docs/tasks/tools/ and ensure it is available."
)
_CANNOT_REACH_API = (
"Cannot reach the Kubernetes API server. "
"Start your cluster (e.g. minikube start, kind create cluster, k3d cluster create) "
"and verify your kubeconfig with: kubectl config current-context"
)
def _raise_connection_error(error_msg: str, fallback_msg: str) -> None:
"""Raise RuntimeError with a user-friendly message based on kubectl's connection error output."""
if "connect: no route to host" in error_msg or "dial tcp" in error_msg:
raise RuntimeError(
"Cannot reach the Kubernetes API server (no route to host). "
"Start your cluster (e.g. minikube start, kind create cluster) "
"and verify your kubeconfig with: kubectl config current-context"
)
if "Unable to connect to the server" in error_msg:
raise RuntimeError(_CANNOT_REACH_API)
if "connection refused" in error_msg.lower():
raise RuntimeError(
"Connection to the Kubernetes API server was refused. "
"Make sure your cluster is running and the API server is accessible."
)
raise RuntimeError(fallback_msg)
def _run_kubectl(cmd: List[str], timeout: int) -> subprocess.CompletedProcess:
"""
Run a kubectl command and return the completed process.
Raises RuntimeError if kubectl is not installed or the command times out.
CalledProcessError propagates for the caller to handle context-specifically.
"""
try:
return subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout)
except FileNotFoundError:
raise RuntimeError(_KUBECTL_NOT_FOUND)
except subprocess.TimeoutExpired:
raise RuntimeError(
f"kubectl timed out after {timeout}s. "
"Check that your cluster is running and reachable, then try again."
)
def get_namespaces() -> List[str]:
"""Retrieve the sorted list of namespace names from the connected Kubernetes cluster via kubectl."""
try:
proc = _run_kubectl(["kubectl", "get", "namespaces", "-o", "json"], timeout=20)
result = json.loads(proc.stdout)
return sorted([item["metadata"]["name"] for item in result.get("items", [])])
except subprocess.CalledProcessError as e:
error_msg = e.stderr.strip() if e.stderr else str(e)
_raise_connection_error(error_msg, f"kubectl error while fetching namespaces: {error_msg[:200]}")
except RuntimeError:
raise
except json.JSONDecodeError as e:
raise RuntimeError(f"Could not parse kubectl output: {str(e)}")
except Exception as e:
raise RuntimeError(f"Unexpected error while fetching namespaces: {str(e)}")
def get_resource_types() -> List[Dict[str, Any]]:
"""
Retrieve all resource types known by the cluster via kubectl api-resources.
Each entry includes name, shortNames, namespaced scope flag, and isCommon flag.
Common types exclude events and endpoints to avoid noisy intermediate resources.
"""
try:
proc = _run_kubectl(
["kubectl", "api-resources", "--verbs=list", "--no-headers"], timeout=30
)
resources = []
seen = set()
for line in proc.stdout.strip().splitlines():
tokens = line.split()
if len(tokens) < 4:
continue
name = tokens[0]
# Deduplicate by simple name (e.g. "deployments" from "deployments.apps")
simple_name = name.split('.')[0]
if simple_name in seen:
continue
seen.add(simple_name)
# kubectl api-resources columns: NAME [SHORTNAMES] APIVERSION NAMESPACED KIND
# NAMESPACED is always second-to-last, KIND is last
namespaced = tokens[-2].lower() == 'true'
short_names = [tokens[1]] if len(tokens) >= 5 else []
resources.append({
"name": simple_name,
"shortNames": short_names,
"namespaced": namespaced,
"isCommon": simple_name in COMMON_RESOURCE_TYPES,
})
resources.sort(key=lambda x: (not x['isCommon'], x['name']))
return resources
except subprocess.CalledProcessError as e:
error_msg = e.stderr.strip() if e.stderr else str(e)
_raise_connection_error(error_msg, f"kubectl error while fetching resource types: {error_msg[:200]}")
except RuntimeError:
raise
except Exception as e:
raise RuntimeError(f"Unexpected error while fetching resource types: {str(e)}")
def get_current_context() -> str:
"""Return the name of the currently active kubectl context."""
try:
proc = _run_kubectl(["kubectl", "config", "current-context"], timeout=5)
return proc.stdout.strip()
except subprocess.CalledProcessError as e:
error_msg = e.stderr.strip() if e.stderr else str(e)
raise RuntimeError(f"No active kubectl context found: {error_msg}")
except RuntimeError:
raise
except Exception as e:
raise RuntimeError(f"Unexpected error while fetching context: {str(e)}")
def _make_diagrams_error(stdout: str, stderr: str, cmd: List[str]) -> DiagramResult:
"""Return a DiagramResult describing why kubectl-diagrams failed."""
if "Unable to connect" in stderr or "connect: no route to host" in stderr:
return DiagramResult(
success=False,
error="Cannot reach the Kubernetes API server. "
"Start your cluster (e.g. minikube start, kind create cluster) "
"and verify your kubeconfig with: kubectl config current-context",
command=" ".join(cmd),
stdout=stdout,
stderr=stderr,
)
return DiagramResult(
success=False,
error="kubectl-diagrams failed. See command output below.",
command=" ".join(cmd),
stdout=stdout,
stderr=stderr,
)
def generate_from_cluster(
resource_types: List[str],
namespace: Optional[str] = None,
all_namespaces: bool = False,
output_format: str = "png",
extra_args: str = "",
without_namespace: bool = False
) -> DiagramResult:
"""Generate diagram using kubectl-diagrams plugin directly."""
cmd: List[str] = []
try:
resources_arg = ','.join(resource_types)
base_name = f"cluster-diagram-{uuid.uuid4().hex[:8]}"
base_path = os.path.join(tempfile.gettempdir(), base_name)
requested_output, png_output = FileManager.get_output_paths(base_path, output_format)
cmd = ["kubectl-diagrams", resources_arg]
if all_namespaces:
cmd.append("--all-namespaces")
elif namespace:
cmd.extend(["-n", namespace])
cmd.extend(["-o", requested_output])
if output_format != "png":
cmd.extend(["-f", output_format])
if without_namespace:
cmd.append("--without-namespace")
if extra_args.strip():
cmd.extend(parse_extra_args(extra_args))
proc = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
stdout_output = proc.stdout or ""
stderr_output = proc.stderr or ""
if proc.returncode != 0 or has_fatal_error(stdout_output, stderr_output):
return _make_diagrams_error(stdout_output, stderr_output, cmd)
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)
return DiagramResult(
success=True,
diagram=encoded,
mime_type=MIME_TYPES.get(produced_format, "application/octet-stream"),
filename=f"{base_name}.{produced_format}",
message="Diagram successfully generated from cluster resources using kubectl-diagrams.",
command=" ".join(cmd),
stdout=stdout_output,
stderr=stderr_output
)
except FileNotFoundError:
return DiagramResult(
success=False,
error="kubectl-diagrams is not installed or not found in PATH. "
"Install the kubectl-diagrams plugin and ensure kubectl is configured: "
"kubectl config current-context",
command="kubectl-diagrams"
)
except subprocess.TimeoutExpired:
return DiagramResult(
success=False,
error="Command timed out. The cluster might be slow or unresponsive.",
command=" ".join(cmd) or "kubectl-diagrams"
)
except Exception as e:
return DiagramResult(
success=False,
error=f"Unexpected error: {str(e)}",
command=" ".join(cmd) or "kubectl-diagrams"
)
+3 -4
View File
@@ -25,11 +25,11 @@ def generate_from_helm(
Returns:
DiagramResult: Result of the generation
"""
# Extraction du nom de base
# Extract base name for output file
parsed = urlparse(chart_url)
base_name = os.path.basename(parsed.path).replace(".tgz", "").replace(".tar.gz", "")
# Pour les URLs OCI
# OCI URLs use the last path segment as chart name
if chart_url.startswith('oci://'):
base_name = chart_url.rstrip('/').split('/')[-1]
@@ -42,12 +42,11 @@ def generate_from_helm(
if extra_args.strip():
cmd.extend(parse_extra_args(extra_args))
# Execution
# Run the command and capture output
proc = subprocess.run(cmd, check=False, capture_output=True, text=True)
stdout_output = proc.stdout or ""
stderr_output = proc.stderr or ""
# First we verify if there was an error before file exist
has_error = proc.returncode != 0 or has_fatal_error(stdout_output, stderr_output)
# Second we verify if there was an error in the stderr output
+24 -21
View File
@@ -1,4 +1,4 @@
"""Utilitaires pour les services de génération de diagrammes."""
"""Utilities for diagram generation services."""
import base64
import shlex
@@ -6,30 +6,30 @@ from constants import TEXT_FORMATS
def has_fatal_error(stdout_txt: str, stderr_txt: str) -> bool:
"""
Vérifie si la sortie contient une erreur fatale.
Check whether subprocess output contains a fatal error marker.
Args:
stdout_txt: Sortie standard
stderr_txt: Sortie d'erreur
stdout_txt: Standard output text
stderr_txt: Standard error text
Returns:
bool: True si erreur fatale détectée
bool: True if a fatal error was detected
"""
return ("error:" in (stdout_txt or "").lower()) or ("error:" in (stderr_txt or "").lower())
def parse_extra_args(extra_args: str) -> list[str]:
"""
Parse les arguments supplémentaires.
Parse a string of extra CLI arguments using shell-like tokenization.
Args:
extra_args: Arguments supplémentaires en string
extra_args: Space-separated argument string (may include quoted tokens)
Returns:
list[str]: Liste des arguments parsés
list[str]: Parsed argument tokens
Raises:
ValueError: Si les arguments sont invalides
ValueError: If the argument string has invalid shell syntax
"""
if not extra_args or not extra_args.strip():
return []
@@ -41,15 +41,18 @@ def parse_extra_args(extra_args: str) -> list[str]:
def encode_content(content: bytes, output_format: str) -> str:
"""
Encode le contenu en base64 ou UTF-8 selon le format.
Encode diagram content for JSON transport.
Text-based formats (SVG, DOT, DOT_JSON, DRAWIO) are decoded as UTF-8.
Binary formats (PNG, JPG, PDF) are base64-encoded.
Args:
content: Contenu à encoder
output_format: Format de sortie
content: Raw file content
output_format: Output format string (e.g. 'png', 'svg')
Returns:
str: Contenu encodé
str: Encoded content ready for the API response
"""
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")
+20 -2
View File
@@ -75,7 +75,7 @@ class InputValidator:
url: URL of the Helm chart
Returns:
Tuple[bool, Optional[str]]: (est_valide, message_erreur)
Tuple[bool, Optional[str]]: (is_valid, error_message)
"""
if not url or not url.strip():
return False, "Chart URL cannot be empty."
@@ -121,7 +121,7 @@ class InputValidator:
Tuple[bool, Optional[str]]: (is_valid, error_message)
"""
if not args or not args.strip():
return True, None # Les args vides sont valides
return True, None
dangerous_chars = [';', '&', '|', '`', '$', '(', ')']
for char in dangerous_chars:
@@ -130,6 +130,24 @@ class InputValidator:
return True, None
@staticmethod
def validate_k8s_name(name: str) -> bool:
"""
Validate a Kubernetes resource name.
Args:
name: Resource name to validate
Returns:
bool: True if valid
"""
if not name or not isinstance(name, str):
return False
# Kubernetes names must be lowercase alphanumeric, with hyphens allowed
# Must start and end with alphanumeric
pattern = re.compile(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')
return bool(pattern.match(name)) and len(name) <= 253
@staticmethod
def looks_like_manifest(text: str) -> bool:
"""
+7
View File
@@ -43,8 +43,13 @@ services:
volumes:
# Persist logs outside container
- ./backend/logs:/app/logs
# Mount host kubeconfig so kubectl can reach the cluster started on the host
- ${HOME}/.kube:/root/.kube:ro
# Mount minikube certs at the same absolute path as on the host (kubeconfig references them by absolute path)
- ${HOME}/.minikube:${HOME}/.minikube:ro
networks:
- kubediagrams-network
- minikube
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:5000/api/health"]
@@ -59,3 +64,5 @@ services:
networks:
kubediagrams-network:
driver: bridge
minikube:
external: true
+1 -1
View File
@@ -21,7 +21,7 @@ function App() {
setRestoredItem(null);
};
// Mémoriser historyContext pour éviter de le recréer à chaque render
// Memoize historyContext to avoid recreating it on every render
const historyContext = useMemo(
() => ({ addToHistory, getHistoryItem, restoredItem, clearRestoredItem }),
[addToHistory, getHistoryItem, restoredItem]
@@ -120,10 +120,8 @@ const HistoryPanel = ({ history, onRestore, onRemove, onClear, isOpen, onToggle
{history.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-500">
<FileText className="w-16 h-16 mb-4 opacity-50" />
<p className="text-center">Aucun diagramme dans l&apos;historique</p>
<p className="text-xs text-center mt-2">
Les diagrammes générés apparaîtront ici
</p>
<p className="text-center">No diagrams in history</p>
<p className="text-xs text-center mt-2">Generated diagrams will appear here</p>
</div>
) : (
history.map((item) => (
@@ -14,7 +14,7 @@ export default function PanZoomContainer({
scrollPanSpeed = 3.0,
buttonZoomFactor = 1.25,
buttonZoomFactorFast = 1.5,
// Souris
// Mouse
mouseZoomDampen = 0.018,
clampDeltaY = 50,
}) {
@@ -22,17 +22,17 @@ export default function PanZoomContainer({
const measureRef = useRef(null);
const isInControls = (el) => !!(el && el.closest && el.closest('.kd-controls'));
// États affichés
// Displayed state (reactive)
const [scale, _setScale] = useState(1);
const [tx, _setTx] = useState(0);
const [ty, _setTy] = useState(0);
// Réfs calculs + animation
// Refs for calculation and animation (non-reactive)
const scaleRef = useRef(1);
const txRef = useRef(0);
const tyRef = useRef(0);
// Cibles animées
// Animated targets
const targetScaleRef = useRef(1);
const targetTxRef = useRef(0);
const targetTyRef = useRef(0);
@@ -55,7 +55,7 @@ export default function PanZoomContainer({
const [naturalSize, setNaturalSize] = useState({ w: 0, h: 0 });
const [vpSize, setVpSize] = useState({ w: 0, h: 0 });
// Mesures
// Measure natural content size and viewport size
useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
@@ -83,7 +83,7 @@ export default function PanZoomContainer({
};
}, []);
// Fit initial
// Fit content into viewport on first render
useEffect(() => {
if (!naturalSize.w || !naturalSize.h || !vpSize.w || !vpSize.h) return;
fit(true);
@@ -91,7 +91,7 @@ export default function PanZoomContainer({
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
// Anime position/zoom vers la cible
// Animate position/zoom toward targets with smoothing
const animate = useCallback(() => {
const s = scaleRef.current;
const tx = txRef.current;
@@ -167,7 +167,7 @@ export default function PanZoomContainer({
dY *= vp.clientHeight;
}
// --- ZOOM (pinch/ctrl) : adoucir la souris, garder le trackpad tel quel
// --- ZOOM (pinch/ctrl): dampen mouse wheel, keep trackpad as-is
if (e.ctrlKey) {
const isTrackpad = isTrackpadWheel(e);
const sens = isTrackpad ? pinchSensitivity : pinchSensitivity * mouseZoomDampen;
@@ -178,7 +178,7 @@ export default function PanZoomContainer({
return;
}
// --- Mode "zoom" à la molette : même adoucissement pour souris
// --- Wheel zoom mode: same dampening as pinch/ctrl
if (wheelMode === 'zoom') {
const isTrackpad = isTrackpadWheel(e);
const sens = isTrackpad ? wheelSensitivity : wheelSensitivity * mouseZoomDampen;
@@ -189,7 +189,7 @@ export default function PanZoomContainer({
return;
}
// --- Par défaut : PAN au wheel/trackpad (deux doigts)
// --- Default: pan with wheel/trackpad (two-finger scroll)
const k = scrollPanSpeed;
targetTxRef.current = txRef.current - dX * k;
targetTyRef.current = tyRef.current - dY * k;
@@ -9,8 +9,8 @@ const ProgressBar = ({ currentStep = 'idle', isVisible = false }) => {
const steps = [
{ id: 'parsing', label: 'Parsing' },
{ id: 'validation', label: 'Validation' },
{ id: 'generation', label: 'Génération' },
{ id: 'rendering', label: 'Rendu' },
{ id: 'generation', label: 'Generation' },
{ id: 'rendering', label: 'Rendering' },
];
const getStepStatus = (stepId) => {
@@ -65,7 +65,7 @@ const ProgressBar = ({ currentStep = 'idle', isVisible = false }) => {
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-slate-300">
{currentStep === 'completed' ? '✓ Génération terminée' : 'Génération en cours...'}
{currentStep === 'completed' ? '✓ Generation complete' : 'Generation in progress...'}
</h3>
{currentStep !== 'completed' && currentStep !== 'error' && (
<Loader2 className="w-4 h-4 animate-spin text-blue-400" />
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import ManifestTab from '../tabs/ManifestTab/index.jsx';
import HelmTab from '../tabs/HelmTab/index.jsx';
import HelmFileTab from '../tabs/HelmFileTab/index.jsx';
import ClusterTab from '../tabs/ClusterTab/index.jsx';
import InteractiveViewerTab from '../tabs/InteractiveViewerTab/index.jsx';
function Tabs({ historyContext }) {
@@ -16,6 +17,7 @@ function Tabs({ historyContext }) {
if (type === 'manifest') setActiveTab('manifest');
else if (type === 'helm') setActiveTab('helm');
else if (type === 'helmfile') setActiveTab('helmfile');
else if (type === 'cluster') setActiveTab('cluster');
}
}, [historyContext?.restoredItem]);
@@ -23,12 +25,13 @@ function Tabs({ historyContext }) {
{ id: 'manifest', label: 'Manifest' },
{ id: 'helm', label: 'Helm Chart' },
{ id: 'helmfile', label: 'HelmFile' },
{ id: 'cluster', label: 'Cluster' },
{ id: 'interactviewer', label: 'InteractiveViewer' },
];
return (
<div className="flex flex-col flex-1 w-full">
{/* Onglets */}
{/* Tabs */}
<div className="flex mb-4 space-x-4">
{tabs.map((tab) => (
<motion.button
@@ -48,11 +51,12 @@ function Tabs({ historyContext }) {
))}
</div>
{/* Contenu des tabs */}
{/* Tab content */}
<div className="flex-1">
{activeTab === 'manifest' && <ManifestTab historyContext={historyContext} />}
{activeTab === 'helm' && <HelmTab historyContext={historyContext} />}
{activeTab === 'helmfile' && <HelmFileTab historyContext={historyContext} />}
{activeTab === 'cluster' && <ClusterTab historyContext={historyContext} />}
{activeTab === 'interactviewer' && <InteractiveViewerTab />}
</div>
</div>
@@ -0,0 +1,415 @@
/**
* Cluster Input Component
* Stateless presentational component for cluster resource selection and diagram options.
* All state and handlers are provided by ClusterTab/index.jsx via useClusterData.
*/
import PropTypes from 'prop-types';
import { Info, Server, RefreshCw, Layers, Search } from 'lucide-react';
import SubmitButton from '../../common/SubmitButton.jsx';
import ManifestOptions from '../../options/ManifestOptions';
function ClusterInput({
// Input state (from ClusterTab/index.jsx)
namespace,
resourceTypes,
allNamespaces,
outputFormat,
setOutputFormat,
extraArgs,
setExtraArgs,
withoutNamespace,
setWithoutNamespace,
errorMessage,
isSubmitting,
onSubmit,
// Cluster data (from useClusterData via index.jsx)
currentContext,
namespaces,
availableResourceTypes,
loadingNamespaces,
loadingResourceTypes,
resourceTypeSearch,
setResourceTypeSearch,
filteredResourceTypes,
commonVisible,
otherVisible,
fetchContext,
fetchNamespaces,
handleRefreshResourceTypes,
handleResourceTypeToggle,
handleSelectCommon,
handleSelectAll,
handleClearSelection,
handleAllNamespacesToggle,
handleNamespaceChange,
}) {
return (
<div className="w-full flex flex-col bg-[var(--color-panel)] p-6 rounded-lg shadow-lg space-y-4">
<div className="flex items-center gap-2">
<Server className="w-6 h-6" />
<h2 className="text-2xl font-bold">Cluster Resources</h2>
</div>
{currentContext && (
<p className="text-xs text-gray-400 -mt-2">
Context:{' '}
<code className="text-green-400 bg-gray-800 px-1.5 py-0.5 rounded">{currentContext}</code>
</p>
)}
<div className="space-y-4">
{/* All Namespaces Checkbox */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allNamespaces"
checked={allNamespaces}
onChange={(e) => handleAllNamespacesToggle(e.target.checked)}
className="w-4 h-4 rounded bg-gray-700 border-gray-600"
/>
<label htmlFor="allNamespaces" className="text-sm font-medium text-white">
All Namespaces
</label>
</div>
{/* Namespace Selection */}
{!allNamespaces && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-white">Namespace</label>
<button
type="button"
onClick={() => {
fetchNamespaces();
fetchContext();
}}
disabled={loadingNamespaces}
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
>
<RefreshCw className={`w-3 h-3 ${loadingNamespaces ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
<select
className="w-full p-3 rounded-lg bg-gray-700 text-white"
value={namespace}
onChange={(e) => handleNamespaceChange(e.target.value)}
disabled={loadingNamespaces || namespaces.length === 0}
>
<option value="">
{namespaces.length === 0
? 'No namespaces available - check cluster connection'
: 'Select a namespace'}
</option>
{namespaces.map((ns) => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
{namespaces.length === 0 && !loadingNamespaces && (
<p className="text-xs text-yellow-400 mt-1">
No namespaces found. Please ensure your Kubernetes cluster is running (e.g.,{' '}
<code className="bg-gray-800 px-1 rounded">minikube start</code>) and click the
Refresh button.
</p>
)}
{namespaces.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
Select the namespace to retrieve resources from.
</p>
)}
</div>
)}
{/* Resource Types Selection */}
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-white">
<Layers className="inline w-4 h-4 mr-1" />
Resource Types
</label>
<button
type="button"
onClick={handleRefreshResourceTypes}
disabled={loadingResourceTypes}
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 disabled:opacity-40"
>
<RefreshCw className={`w-3 h-3 ${loadingResourceTypes ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
<div className="flex items-center gap-2 text-xs mt-1">
<button
type="button"
onClick={handleSelectCommon}
disabled={loadingResourceTypes || availableResourceTypes.length === 0}
className="text-blue-400 hover:text-blue-300 disabled:opacity-40"
>
Select Common
</button>
<span className="text-gray-500">|</span>
<button
type="button"
onClick={handleSelectAll}
disabled={loadingResourceTypes || availableResourceTypes.length === 0}
className="text-blue-400 hover:text-blue-300 disabled:opacity-40"
>
Select All
</button>
<span className="text-gray-500">|</span>
<button
type="button"
onClick={handleClearSelection}
className="text-red-400 hover:text-red-300"
>
Deselect All
</button>
</div>
</div>
{/* Scope hint */}
{availableResourceTypes.length > 0 && (
<p className="text-xs text-gray-500 mb-2">
{resourceTypeSearch
? `${filteredResourceTypes.length} of ${availableResourceTypes.length} types`
: `${availableResourceTypes.length} types known by the cluster`}
{namespace && !allNamespaces && !resourceTypeSearch && (
<span>
{' '}
resources tagged <span className="text-purple-400 font-medium">
cluster
</span>{' '}
are not bound to a namespace but can still be included (e.g. nodes for pod
placement).
</span>
)}
</p>
)}
{availableResourceTypes.length > 0 && (
<div className="relative mb-2">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none" />
<input
type="text"
value={resourceTypeSearch}
onChange={(e) => setResourceTypeSearch(e.target.value)}
placeholder="Search resource types…"
className="w-full pl-7 pr-3 py-1.5 text-xs rounded bg-gray-700 text-white placeholder-gray-500 border border-gray-600 focus:outline-none focus:border-blue-500"
/>
</div>
)}
<div className="bg-gray-800 p-4 rounded-lg max-h-64 overflow-y-auto">
{loadingResourceTypes ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="w-5 h-5 animate-spin text-blue-400 mr-2" />
<span className="text-sm text-gray-400">Loading resource types from cluster</span>
</div>
) : filteredResourceTypes.length === 0 && resourceTypeSearch ? (
<div className="text-center py-4">
<p className="text-sm text-gray-400">
No resource types match{' '}
<strong className="text-white">"{resourceTypeSearch}"</strong>.
</p>
</div>
) : availableResourceTypes.length === 0 ? (
<div className="text-center py-4">
<p className="text-sm text-gray-400">
No resource types available. Please check your cluster connection.
</p>
<button
type="button"
onClick={handleRefreshResourceTypes}
className="text-xs text-blue-400 hover:text-blue-300 mt-2"
>
<RefreshCw className="inline w-3 h-3 mr-1" />
Retry
</button>
</div>
) : (
<>
{commonVisible.length > 0 && (
<div className="mb-4">
<h4 className="text-xs font-semibold text-gray-400 mb-2 uppercase">
Common Resources
</h4>
<div className="grid grid-cols-2 gap-2">
{commonVisible.map((rt) => (
<ResourceTypeItem
key={rt.name}
rt={rt}
checked={resourceTypes.includes(rt.name)}
onToggle={handleResourceTypeToggle}
/>
))}
</div>
</div>
)}
{otherVisible.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-400 mb-2 uppercase">
Other Resources
</h4>
<div className="grid grid-cols-2 gap-2">
{otherVisible.map((rt) => (
<ResourceTypeItem
key={rt.name}
rt={rt}
checked={resourceTypes.includes(rt.name)}
onToggle={handleResourceTypeToggle}
/>
))}
</div>
</div>
)}
</>
)}
</div>
<p
className={`text-xs mt-1 ${resourceTypes.length === 0 ? 'text-yellow-400' : 'text-gray-400'}`}
>
{resourceTypes.length > 0
? `${resourceTypes.length} type${resourceTypes.length > 1 ? 's' : ''} selected`
: 'No types selected — please select at least one resource type.'}
</p>
</div>
</div>
<ManifestOptions
outputFormat={outputFormat}
setOutputFormat={setOutputFormat}
extraArgs={extraArgs}
setExtraArgs={setExtraArgs}
withoutNamespace={withoutNamespace}
setWithoutNamespace={setWithoutNamespace}
/>
<SubmitButton
onClick={onSubmit}
className="w-full mt-2"
disabled={isSubmitting || (!allNamespaces && !namespace) || resourceTypes.length === 0}
>
{isSubmitting ? 'Generating…' : 'Generate Cluster Diagram'}
</SubmitButton>
{/* Help Message */}
{!isSubmitting && (
<div
className={`border rounded-lg p-4 ${
namespaces.length === 0
? 'bg-yellow-900/20 border-yellow-500/50'
: 'bg-blue-900/30 border-blue-500/50'
}`}
>
<p
className={`text-sm flex items-start gap-2 ${
namespaces.length === 0 ? 'text-yellow-300' : 'text-blue-300'
}`}
>
<Info className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>
{namespaces.length === 0 ? (
<>
<strong>No cluster detected.</strong> Start your cluster, then click{' '}
<strong>Refresh</strong>:
<br /> minikube: <code className="bg-gray-800 px-1 rounded">minikube start</code>
<br /> kind:{' '}
<code className="bg-gray-800 px-1 rounded">kind create cluster</code>
<br /> k3d: <code className="bg-gray-800 px-1 rounded">k3d cluster create</code>
<br />
Make sure <code className="bg-gray-800 px-1 rounded">kubectl</code> is installed
and your kubeconfig points to the right context.
</>
) : (
<>
Retrieves resources from your cluster via kubectl and generates a diagram. Make
sure kubectl is configured with the correct context (
<code className="bg-gray-800 px-1 rounded">kubectl config current-context</code>
).
</>
)}
</span>
</p>
</div>
)}
</div>
);
}
function ResourceTypeItem({ rt, checked, onToggle }) {
return (
<label className="flex items-center gap-2 text-sm text-white cursor-pointer hover:bg-gray-700 p-2 rounded">
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(rt.name)}
className="w-4 h-4 rounded bg-gray-700 border-gray-600 shrink-0"
/>
<span className="flex items-center gap-1 min-w-0">
<span className="truncate" title={rt.name}>
{rt.name}
{rt.shortNames?.length > 0 && (
<span className="text-xs text-gray-500 ml-1">({rt.shortNames.join(', ')})</span>
)}
</span>
{!rt.namespaced && (
<span
className="text-xs text-purple-400 shrink-0"
title="Cluster-scoped — not bound to a single namespace"
>
cluster
</span>
)}
</span>
</label>
);
}
ResourceTypeItem.propTypes = {
rt: PropTypes.shape({
name: PropTypes.string.isRequired,
shortNames: PropTypes.arrayOf(PropTypes.string),
namespaced: PropTypes.bool.isRequired,
}).isRequired,
checked: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
};
ClusterInput.propTypes = {
namespace: PropTypes.string.isRequired,
resourceTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
allNamespaces: PropTypes.bool.isRequired,
outputFormat: PropTypes.string.isRequired,
setOutputFormat: PropTypes.func.isRequired,
extraArgs: PropTypes.string.isRequired,
setExtraArgs: PropTypes.func.isRequired,
withoutNamespace: PropTypes.bool.isRequired,
setWithoutNamespace: PropTypes.func.isRequired,
errorMessage: PropTypes.string,
isSubmitting: PropTypes.bool.isRequired,
onSubmit: PropTypes.func.isRequired,
currentContext: PropTypes.string.isRequired,
namespaces: PropTypes.arrayOf(PropTypes.string).isRequired,
availableResourceTypes: PropTypes.array.isRequired,
loadingNamespaces: PropTypes.bool.isRequired,
loadingResourceTypes: PropTypes.bool.isRequired,
resourceTypeSearch: PropTypes.string.isRequired,
setResourceTypeSearch: PropTypes.func.isRequired,
filteredResourceTypes: PropTypes.array.isRequired,
commonVisible: PropTypes.array.isRequired,
otherVisible: PropTypes.array.isRequired,
fetchContext: PropTypes.func.isRequired,
fetchNamespaces: PropTypes.func.isRequired,
handleRefreshResourceTypes: PropTypes.func.isRequired,
handleResourceTypeToggle: PropTypes.func.isRequired,
handleSelectCommon: PropTypes.func.isRequired,
handleSelectAll: PropTypes.func.isRequired,
handleClearSelection: PropTypes.func.isRequired,
handleAllNamespacesToggle: PropTypes.func.isRequired,
handleNamespaceChange: PropTypes.func.isRequired,
};
export default ClusterInput;
@@ -0,0 +1,58 @@
/**
* Cluster Output Component
* Displays the generated cluster diagram and command execution details
*/
import { motion } from 'motion/react';
import NotationOptions from '../../options/NotationOptions';
import DiagramViewer from '../../common/DiagramViewer.jsx';
import ErrorAlert from '../../common/ErrorAlert.jsx';
import DownloadButton from '../../common/DownloadButton.jsx';
function ClusterOutput({
errorMessage,
diagram,
mimeType,
filename,
outputFormat,
viewerKey,
viewerRef,
onViewerLoad,
isSubmitting,
}) {
return (
<div className="w-full flex flex-col bg-white p-6 rounded-lg shadow-lg text-black space-y-4">
<h2 className="text-2xl font-bold">Cluster Diagram</h2>
{/* Error Alert */}
<ErrorAlert message={errorMessage} />
{/* Diagram Display */}
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }}>
<DiagramViewer
diagram={diagram}
mimeType={mimeType}
outputFormat={outputFormat}
viewerKey={viewerKey}
viewerRef={viewerRef}
onViewerLoad={onViewerLoad}
isLoading={isSubmitting}
/>
</motion.div>
{/* Download Button */}
<DownloadButton
diagram={diagram}
mimeType={mimeType}
outputFormat={outputFormat}
filename={filename}
filenameFallback={`cluster-diagram.png`}
/>
{/* Notation Options */}
{diagram && <NotationOptions diagramType="cluster" />}
</div>
);
}
export default ClusterOutput;
@@ -0,0 +1,231 @@
/**
* Cluster Tab Container
* Main component that orchestrates live cluster diagram generation
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import PropTypes from 'prop-types';
import { DEFAULTS } from '../../../utils/constants.js';
import { generateClusterDiagram } from '../../../services/diagramApi.js';
import { useViewerSync } from '../../../hooks/useViewerSync.js';
import { useDiagramGeneration } from '../../../hooks/useDiagramGeneration.js';
import { useClusterData } from '../../../hooks/useClusterData.js';
import ClusterInput from './ClusterInput.jsx';
import ClusterOutput from './ClusterOutput.jsx';
import CommandDetails from '../../common/CommandDetails.jsx';
function ClusterTab({ historyContext }) {
// Input states
const [namespace, setNamespace] = useState('');
const [resourceTypes, setResourceTypes] = useState([]);
const [allNamespaces, setAllNamespaces] = useState(false);
const [outputFormat, setOutputFormat] = useState(DEFAULTS.OUTPUT_FORMAT);
const [extraArgs, setExtraArgs] = useState('');
const [withoutNamespace, setWithoutNamespace] = useState(false);
// Diagram generation hook
const {
diagram,
command,
message,
mimeType,
filename,
stdout,
stderr,
errorMessage,
setErrorMessage,
isSubmitting,
viewerKey,
handleSubmit: generateDiagram,
resetOutput,
} = useDiagramGeneration({
apiFunction: generateClusterDiagram,
validateInput: (params) => {
// Validation: either a namespace or allNamespaces must be selected
if (!params.allNamespaces && !params.namespace) {
return 'Please select a namespace or check "All Namespaces".';
}
return null;
},
diagramType: 'cluster',
});
// Track previous outputFormat to detect changes
const prevOutputFormatRef = useRef(outputFormat);
// Reset output when output format changes (not on initial render)
useEffect(() => {
if (prevOutputFormatRef.current !== outputFormat && diagram) {
resetOutput();
}
prevOutputFormatRef.current = outputFormat;
}, [outputFormat, diagram, resetOutput]);
// Viewer synchronization hook for DOT_JSON format
const { viewerRef, handleViewerLoad } = useViewerSync({ diagram, outputFormat });
// Cluster connectivity: namespaces, resource types, context, and all related handlers
const {
currentContext,
namespaces,
availableResourceTypes,
loadingNamespaces,
loadingResourceTypes,
resourceTypeSearch,
setResourceTypeSearch,
filteredResourceTypes,
commonVisible,
otherVisible,
fetchContext,
fetchNamespaces,
handleRefreshResourceTypes,
handleResourceTypeToggle,
handleSelectCommon,
handleSelectAll,
handleClearSelection,
handleAllNamespacesToggle,
handleNamespaceChange,
} = useClusterData({
resourceTypes,
setResourceTypes,
namespace,
setNamespace,
allNamespaces,
setAllNamespaces,
setErrorMessage,
});
// Auto-scroll to output when diagram is ready
// History restoration
const hasRestoredRef = useRef(false);
useEffect(() => {
if (historyContext?.restoredItem && !hasRestoredRef.current) {
const item = historyContext.restoredItem;
if (item.type === 'cluster') {
setNamespace(item.input?.namespace || '');
setResourceTypes(item.input?.resourceTypes || []);
setAllNamespaces(item.input?.allNamespaces || false);
setOutputFormat(item.outputFormat || DEFAULTS.OUTPUT_FORMAT);
setExtraArgs(item.extraArgs || '');
setWithoutNamespace(item.withoutNamespace || false);
hasRestoredRef.current = true;
}
}
}, [historyContext?.restoredItem]);
// History tracking
useEffect(() => {
if (diagram && historyContext?.addToHistory) {
historyContext.addToHistory({
type: 'cluster',
input: { namespace, resourceTypes, allNamespaces },
outputFormat,
extraArgs,
withoutNamespace,
result: {
diagram,
mimeType,
filename,
message,
command,
stdout,
stderr,
},
timestamp: Date.now(),
});
}
}, [diagram]);
// Handle diagram generation with proper parameters
const handleGenerate = useCallback(() => {
generateDiagram({
namespace,
resourceTypes,
allNamespaces,
outputFormat,
extraArgs,
withoutNamespace,
});
}, [
generateDiagram,
namespace,
resourceTypes,
allNamespaces,
outputFormat,
extraArgs,
withoutNamespace,
]);
return (
<div className="flex flex-col gap-6 w-full">
{/* Input and Output Section */}
<div className="flex flex-col lg:flex-row gap-6 w-full">
{/* Input Section */}
<div className="lg:w-1/4">
<ClusterInput
namespace={namespace}
resourceTypes={resourceTypes}
allNamespaces={allNamespaces}
outputFormat={outputFormat}
setOutputFormat={setOutputFormat}
extraArgs={extraArgs}
setExtraArgs={setExtraArgs}
withoutNamespace={withoutNamespace}
setWithoutNamespace={setWithoutNamespace}
errorMessage={errorMessage}
isSubmitting={isSubmitting}
onSubmit={handleGenerate}
currentContext={currentContext}
namespaces={namespaces}
availableResourceTypes={availableResourceTypes}
loadingNamespaces={loadingNamespaces}
loadingResourceTypes={loadingResourceTypes}
resourceTypeSearch={resourceTypeSearch}
setResourceTypeSearch={setResourceTypeSearch}
filteredResourceTypes={filteredResourceTypes}
commonVisible={commonVisible}
otherVisible={otherVisible}
fetchContext={fetchContext}
fetchNamespaces={fetchNamespaces}
handleRefreshResourceTypes={handleRefreshResourceTypes}
handleResourceTypeToggle={handleResourceTypeToggle}
handleSelectCommon={handleSelectCommon}
handleSelectAll={handleSelectAll}
handleClearSelection={handleClearSelection}
handleAllNamespacesToggle={handleAllNamespacesToggle}
handleNamespaceChange={handleNamespaceChange}
/>
</div>
{/* Output Section */}
<div className="lg:w-3/4">
<ClusterOutput
diagram={diagram}
mimeType={mimeType}
filename={filename}
outputFormat={outputFormat}
errorMessage={errorMessage}
viewerKey={viewerKey}
viewerRef={viewerRef}
onViewerLoad={handleViewerLoad}
isSubmitting={isSubmitting}
/>
</div>
</div>
{/* Command Details Section - Full width below */}
{(command || stdout || stderr || message) && (
<CommandDetails command={command} stdout={stdout} stderr={stderr} message={message} />
)}
</div>
);
}
ClusterTab.propTypes = {
historyContext: PropTypes.shape({
restoredItem: PropTypes.object,
addToHistory: PropTypes.func,
}),
};
export default ClusterTab;
@@ -98,7 +98,7 @@ function HelmFileTab({ historyContext }) {
historyContext.addToHistory(historyItem);
lastHistoryIdRef.current = historyId;
}
}, [diagram, progressStep]); // Dépendances minimales pour éviter la boucle
}, [diagram, progressStep]); // minimal deps — avoids a save loop
// Restore from history
useEffect(() => {
@@ -93,7 +93,7 @@ function HelmTab({ historyContext }) {
historyContext.addToHistory(historyItem);
lastHistoryIdRef.current = historyId;
}
}, [diagram, progressStep]); // Dépendances minimales pour éviter la boucle
}, [diagram, progressStep]); // minimal deps — avoids a save loop
// Restore from history
useEffect(() => {
@@ -133,7 +133,7 @@ function HelmTab({ historyContext }) {
setChartUrl(v);
setBackendError('');
// Valider seulement si l'URL n'est pas vide
// Validate only if URL is not empty
if (v && v.trim()) {
setInputError(
isValidChartUrl(v)
@@ -101,7 +101,7 @@ function ManifestTab({ historyContext }) {
historyContext.addToHistory(historyItem);
lastHistoryIdRef.current = historyId;
}
}, [diagram, progressStep]); // Dépendances minimales pour éviter la boucle
}, [diagram, progressStep]); // minimal deps — avoids a save loop
// Restore from history
useEffect(() => {
+228
View File
@@ -0,0 +1,228 @@
import { useState, useEffect, useRef } from 'react';
import { toast } from 'sonner';
import {
getClusterContext,
getClusterNamespaces,
getClusterResourceTypes,
} from '../services/diagramApi.js';
/**
* Manages cluster connectivity state: namespaces, resource types, and active context.
* Handles all fetch logic, auto-selection, and resource-type selection handlers.
*
* @param {Object} params
* @param {string[]} params.resourceTypes - Currently selected resource type names
* @param {Function} params.setResourceTypes
* @param {string} params.namespace - Currently selected namespace
* @param {Function} params.setNamespace
* @param {boolean} params.allNamespaces
* @param {Function} params.setAllNamespaces
* @param {Function} params.setErrorMessage - Called with '' when namespace changes
*/
export function useClusterData({
resourceTypes,
setResourceTypes,
namespace,
setNamespace,
allNamespaces,
setAllNamespaces,
setErrorMessage,
}) {
const [currentContext, setCurrentContext] = useState('');
const [namespaces, setNamespaces] = useState([]);
const [availableResourceTypes, setAvailableResourceTypes] = useState([]);
const [loadingNamespaces, setLoadingNamespaces] = useState(false);
const [loadingResourceTypes, setLoadingResourceTypes] = useState(false);
const [resourceTypeSearch, setResourceTypeSearch] = useState('');
const resourceTypesAutoSelectedRef = useRef(false);
useEffect(() => {
fetchContext();
fetchNamespaces();
fetchResourceTypes();
}, []);
// Auto-select common resources the first time the list loads
useEffect(() => {
if (availableResourceTypes.length > 0 && !resourceTypesAutoSelectedRef.current) {
resourceTypesAutoSelectedRef.current = true;
if (resourceTypes.length === 0) {
setResourceTypes(availableResourceTypes.filter((rt) => rt.isCommon).map((rt) => rt.name));
}
}
}, [availableResourceTypes, resourceTypes, setResourceTypes]);
const fetchContext = async () => {
try {
const response = await getClusterContext();
if (response.ok && response.data?.context) {
setCurrentContext(response.data.context);
}
} catch {
// Silent — context is informational, not blocking
}
};
const fetchNamespaces = async () => {
setLoadingNamespaces(true);
try {
const response = await getClusterNamespaces();
if (response.ok && response.data?.namespaces) {
setNamespaces(response.data.namespaces);
} else {
const errorMsg = response.data?.error || 'Unknown error';
if (
errorMsg.includes('Unable to connect') ||
errorMsg.includes('not running') ||
errorMsg.includes('not accessible') ||
errorMsg.includes('timed out') ||
errorMsg.includes('refused')
) {
toast.error('Cluster not reachable', {
description:
'kubectl cannot connect to your cluster. ' +
'Start it first (e.g. minikube start, kind create cluster, k3d cluster create) ' +
'then click Refresh.',
duration: 10000,
});
} else {
toast.error('Failed to fetch namespaces', { description: errorMsg, duration: 8000 });
}
}
} catch {
toast.error('Network error', {
description: 'Could not connect to the backend. Please ensure the backend is running.',
duration: 5000,
});
} finally {
setLoadingNamespaces(false);
}
};
const fetchResourceTypes = async () => {
setLoadingResourceTypes(true);
try {
const response = await getClusterResourceTypes();
if (response.ok && response.data?.resourceTypes) {
setAvailableResourceTypes(response.data.resourceTypes);
} else {
const errorMsg = response.data?.error || 'Unknown error';
// Suppress duplicate connectivity toasts — already shown by fetchNamespaces
if (
!errorMsg.includes('Unable to connect') &&
!errorMsg.includes('not running') &&
!errorMsg.includes('timed out')
) {
toast.error('Failed to fetch resource types', { description: errorMsg, duration: 5000 });
}
}
} catch {
toast.error('Network error', {
description: 'Could not reach the backend. Please ensure the backend is running.',
duration: 5000,
});
} finally {
setLoadingResourceTypes(false);
}
};
const handleResourceTypeToggle = (type) => {
setResourceTypes((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
const handleSelectCommon = () => {
const commons = availableResourceTypes.filter((rt) => rt.isCommon);
// With a specific namespace, default to namespaced commons only — cluster-scoped
// resources (nodes, storageclasses…) can still be added manually.
const selected = namespace && !allNamespaces ? commons.filter((rt) => rt.namespaced) : commons;
setResourceTypes(selected.map((rt) => rt.name));
};
const handleSelectAll = () => {
setResourceTypes(availableResourceTypes.map((rt) => rt.name));
};
const handleClearSelection = () => {
setResourceTypes([]);
};
const handleRefreshResourceTypes = () => {
setResourceTypeSearch('');
fetchResourceTypes();
};
const handleAllNamespacesToggle = (checked) => {
setAllNamespaces(checked);
if (checked) {
setNamespace('');
// Restore cluster-scoped commons removed when switching to a specific namespace
if (availableResourceTypes.length > 0) {
const clusterScopedCommons = availableResourceTypes
.filter((rt) => rt.isCommon && !rt.namespaced)
.map((rt) => rt.name);
setResourceTypes((prev) => {
const existing = new Set(prev);
const toAdd = clusterScopedCommons.filter((name) => !existing.has(name));
return toAdd.length > 0 ? [...prev, ...toAdd] : prev;
});
}
}
};
const handleNamespaceChange = (newNamespace) => {
setNamespace(newNamespace);
if (setErrorMessage) setErrorMessage('');
// Switching to a specific namespace: remove cluster-scoped resources from the current
// selection — they are not bound to a namespace.
if (newNamespace && availableResourceTypes.length > 0) {
const namespacedNames = new Set(
availableResourceTypes.filter((rt) => rt.namespaced).map((rt) => rt.name)
);
setResourceTypes((prev) => prev.filter((name) => namespacedNames.has(name)));
}
};
// Derived display state
const filteredResourceTypes = resourceTypeSearch
? availableResourceTypes.filter((rt) =>
rt.name.toLowerCase().includes(resourceTypeSearch.toLowerCase())
)
: availableResourceTypes;
const isNamespaceContext = Boolean(namespace && !allNamespaces);
const commonVisible = filteredResourceTypes.filter(
(rt) => rt.isCommon && (!isNamespaceContext || rt.namespaced)
);
const otherVisible = filteredResourceTypes.filter(
(rt) => !rt.isCommon || (isNamespaceContext && !rt.namespaced)
);
return {
// Data
currentContext,
namespaces,
availableResourceTypes,
// Loading flags
loadingNamespaces,
loadingResourceTypes,
// Search / filtered views
resourceTypeSearch,
setResourceTypeSearch,
filteredResourceTypes,
commonVisible,
otherVisible,
// Fetch actions
fetchContext,
fetchNamespaces,
handleRefreshResourceTypes,
// Selection handlers
handleResourceTypeToggle,
handleSelectCommon,
handleSelectAll,
handleClearSelection,
handleAllNamespacesToggle,
handleNamespaceChange,
};
}
+161
View File
@@ -149,6 +149,163 @@ export async function generateHelmfileDiagram({
return response;
}
/**
* Generate diagram from live Kubernetes cluster
* @param {Object} params - Request parameters
* @param {string} [params.namespace] - Namespace to diagram (optional)
* @param {Array<string>} [params.resourceTypes] - Resource types to include
* @param {boolean} [params.allNamespaces] - Include all namespaces
* @param {string} params.outputFormat - Output format (png, jpg, svg, pdf, dot, dot_json)
* @param {string} [params.extraArgs] - Additional CLI arguments
* @param {boolean} [params.withoutNamespace] - Generate without namespace
* @returns {Promise<Object>} Response with diagram data
*/
export async function generateClusterDiagram({
namespace = '',
resourceTypes = [],
allNamespaces = false,
outputFormat,
extraArgs = '',
withoutNamespace = false,
}) {
logger.debug('Requesting Cluster diagram generation', {
namespace,
allNamespaces,
outputFormat,
withoutNamespace,
});
const response = await apiFetch(API_ENDPOINTS.GENERATE_CLUSTER, {
body: JSON.stringify({
namespace,
resourceTypes,
allNamespaces,
outputFormat,
extraArgs,
withoutNamespace,
}),
});
if (response.ok && response.data.diagram) {
logger.info('Cluster diagram generated successfully', {
namespace,
allNamespaces,
outputFormat,
withoutNamespace,
filename: response.data.filename,
});
}
return response;
}
/**
* Get the currently active kubectl context name.
* @returns {Promise<Object>} Response with context name
*/
export async function getClusterContext() {
logger.debug('Fetching current kubectl context');
try {
const response = await fetch(API_ENDPOINTS.CLUSTER_CONTEXT, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
if (response.ok) {
logger.info('kubectl context fetched', { context: data?.context });
}
return { ok: response.ok, status: response.status, data };
} catch (error) {
logger.error('Network error fetching kubectl context', { error });
throw error;
}
}
/**
* Get list of namespaces from Kubernetes cluster
* @returns {Promise<Object>} Response with namespaces list
*/
export async function getClusterNamespaces() {
logger.debug('Fetching cluster namespaces');
try {
const response = await fetch(API_ENDPOINTS.CLUSTER_NAMESPACES, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (!response.ok) {
logger.error('Failed to fetch namespaces', {
statusCode: response.status,
error: data?.error,
});
} else {
logger.info('Namespaces fetched successfully', {
count: data?.namespaces?.length || 0,
});
}
return {
ok: response.ok,
status: response.status,
data,
};
} catch (error) {
logger.error('Network error fetching namespaces', { error });
throw error;
}
}
/**
* Get all resource types known by the Kubernetes cluster.
* Each entry includes name, shortNames, namespaced scope, and isCommon flag.
* The list is fetched once on tab open and cached in the component — use the
* refresh button to re-query the cluster.
* @returns {Promise<Object>} Response with resource types list
*/
export async function getClusterResourceTypes() {
logger.debug('Fetching cluster resource types');
try {
const response = await fetch(API_ENDPOINTS.CLUSTER_RESOURCE_TYPES, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (!response.ok) {
logger.error('Failed to fetch resource types', {
statusCode: response.status,
error: data?.error,
});
} else {
logger.info('Resource types fetched successfully', {
count: data?.resourceTypes?.length || 0,
});
}
return {
ok: response.ok,
status: response.status,
data,
};
} catch (error) {
logger.error('Network error fetching resource types', { error });
throw error;
}
}
/**
* Submit user feedback
* @param {Object} params - Feedback parameters
@@ -198,6 +355,10 @@ export default {
generateManifestDiagram,
generateHelmDiagram,
generateHelmfileDiagram,
generateClusterDiagram,
getClusterContext,
getClusterNamespaces,
getClusterResourceTypes,
submitFeedback,
hasFatalErrors,
};
+4
View File
@@ -27,6 +27,10 @@ export const API_ENDPOINTS = Object.freeze({
GENERATE_MANIFEST: '/api/generate-diagram',
GENERATE_HELM: '/api/generate-helm-diagram',
GENERATE_HELMFILE: '/api/generate-helmfile-diagram',
GENERATE_CLUSTER: '/api/cluster/generate',
CLUSTER_CONTEXT: '/api/cluster/context',
CLUSTER_NAMESPACES: '/api/cluster/namespaces',
CLUSTER_RESOURCE_TYPES: '/api/cluster/resource-types',
SUBMIT_FEEDBACK: '/api/submit-feedback',
EXAMPLES: '/api/examples',
});
+3 -3
View File
@@ -77,9 +77,9 @@ export const dismissToast = (toastId) => {
*/
export const showPromise = (promise, messages) => {
return toast.promise(promise, {
loading: messages.loading || 'Chargement...',
success: messages.success || 'Terminé !',
error: messages.error || 'Une erreur est survenue',
loading: messages.loading || 'Loading...',
success: messages.success || 'Done!',
error: messages.error || 'An error occurred',
});
};