mirror of
https://github.com/philippemerle/KubeDiagrams.git
synced 2026-08-22 14:36:24 +00:00
Merge pull request #74 from Sandor59100/ClusterOption
feat(webapp): add cluster tab
This commit is contained in:
+23
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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(() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user