mirror of
https://github.com/philippemerle/KubeDiagrams.git
synced 2026-08-22 14:36:24 +00:00
[Feature] Refactor cluster tab with improved kubectl handling and stateless UI
This commit is contained in:
@@ -61,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."""
|
||||
|
||||
@@ -6,14 +6,28 @@ 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({
|
||||
@@ -28,9 +42,9 @@ def list_namespaces():
|
||||
|
||||
@cluster_bp.route('/api/cluster/resource-types', methods=['GET'])
|
||||
def list_resource_types():
|
||||
namespace = request.args.get('namespace')
|
||||
"""Return all resource types known by the cluster, tagged with namespace scope and common status."""
|
||||
try:
|
||||
resource_types = get_resource_types(namespace=namespace)
|
||||
resource_types = get_resource_types()
|
||||
return ResponseBuilder.success({
|
||||
"resourceTypes": resource_types,
|
||||
"count": len(resource_types)
|
||||
@@ -58,7 +72,7 @@ def generate_cluster_diagram():
|
||||
route = request.path
|
||||
params = (
|
||||
f"namespace={namespace};"
|
||||
f"resourceTypes={','.join(resource_types) if resource_types else 'default'};"
|
||||
f"resourceTypes={','.join(resource_types)};"
|
||||
f"allNamespaces={all_namespaces};"
|
||||
f"format={output_format};"
|
||||
f"extraArgs={compact_for_log(extra_args)};"
|
||||
@@ -66,6 +80,10 @@ def generate_cluster_diagram():
|
||||
)
|
||||
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")
|
||||
@@ -83,7 +101,7 @@ def generate_cluster_diagram():
|
||||
# Generate diagram using kubectl-diagrams
|
||||
result = generate_from_cluster(
|
||||
namespace=namespace,
|
||||
resource_types=resource_types if resource_types else None,
|
||||
resource_types=resource_types,
|
||||
all_namespaces=all_namespaces,
|
||||
output_format=output_format,
|
||||
extra_args=extra_args,
|
||||
|
||||
@@ -8,6 +8,7 @@ from .clusterService import (
|
||||
generate_from_cluster,
|
||||
get_namespaces,
|
||||
get_resource_types,
|
||||
get_current_context,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -19,4 +20,6 @@ __all__ = [
|
||||
'generate_from_cluster',
|
||||
'get_namespaces',
|
||||
'get_resource_types',
|
||||
'get_current_context',
|
||||
]
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import subprocess
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from constants import MIME_TYPES
|
||||
@@ -10,264 +11,218 @@ 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:
|
||||
cmd = ["kubectl", "get", "namespaces", "-o", "json"]
|
||||
proc = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=20)
|
||||
|
||||
proc = _run_kubectl(["kubectl", "get", "namespaces", "-o", "json"], timeout=20)
|
||||
result = json.loads(proc.stdout)
|
||||
namespaces = [item["metadata"]["name"] for item in result.get("items", [])]
|
||||
|
||||
return sorted(namespaces)
|
||||
# Gestion d'erreurs ici
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
"kubectl is not installed or not in PATH. "
|
||||
"Please install kubectl to use this feature."
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(
|
||||
"Connection to Kubernetes cluster timed out. "
|
||||
"Please check your cluster connectivity and try again."
|
||||
)
|
||||
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)
|
||||
|
||||
# Check for common error patterns
|
||||
if "connect: no route to host" in error_msg or "dial tcp" in error_msg:
|
||||
raise RuntimeError(
|
||||
"Unable to connect to Kubernetes cluster. "
|
||||
"Please ensure your cluster is running (e.g., 'minikube start') "
|
||||
"and kubectl is configured correctly."
|
||||
)
|
||||
elif "Unable to connect to the server" in error_msg:
|
||||
raise RuntimeError(
|
||||
"Kubernetes cluster is not accessible. "
|
||||
"Please start your cluster (e.g., 'minikube start', 'kind create cluster') "
|
||||
"or check your kubeconfig configuration."
|
||||
)
|
||||
elif "connection refused" in error_msg.lower():
|
||||
raise RuntimeError(
|
||||
"Connection to Kubernetes cluster was refused. "
|
||||
"Please verify your cluster is running and accessible."
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Failed to retrieve namespaces from cluster. "
|
||||
f"Error: {error_msg[:200]}"
|
||||
)
|
||||
_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"Failed to parse kubectl output: {str(e)}")
|
||||
raise RuntimeError(f"Could not parse kubectl output: {str(e)}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Unexpected error getting namespaces: {str(e)}")
|
||||
raise RuntimeError(f"Unexpected error while fetching namespaces: {str(e)}")
|
||||
|
||||
|
||||
def get_resource_types(namespace: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
def get_resource_types() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get resource types that actually exist in the namespace/cluster.
|
||||
Includes CRDs (Custom Resource Definitions) and all available resource types.
|
||||
Only returns resources that have at least one instance to avoid empty diagrams.
|
||||
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:
|
||||
# Common resource types to prioritize in display
|
||||
common_types = {
|
||||
'pods', 'services', 'deployments', 'replicasets', 'statefulsets',
|
||||
'daemonsets', 'configmaps', 'secrets', 'ingresses',
|
||||
'persistentvolumeclaims', 'persistentvolumes', 'nodes',
|
||||
'namespaces', 'serviceaccounts', 'roles', 'rolebindings',
|
||||
'clusterroles', 'clusterrolebindings', 'jobs', 'cronjobs',
|
||||
'horizontalpodautoscalers', 'networkpolicies', 'storageclasses',
|
||||
'endpoints', 'events', 'limitranges', 'resourcequotas'
|
||||
}
|
||||
proc = _run_kubectl(
|
||||
["kubectl", "api-resources", "--verbs=list", "--no-headers"], timeout=30
|
||||
)
|
||||
resources = []
|
||||
seen = set()
|
||||
|
||||
# Without namespace, return only common types (no verification needed)
|
||||
if not namespace:
|
||||
return [
|
||||
{"name": name, "shortNames": [], "isCommon": True}
|
||||
for name in sorted(common_types)
|
||||
]
|
||||
|
||||
# Get ALL namespaced resources (including CRDs)
|
||||
namespaced_cmd = ["kubectl", "api-resources", "--verbs=list", "--namespaced=true", "-o", "name"]
|
||||
namespaced_proc = subprocess.run(namespaced_cmd, check=True, capture_output=True, text=True, timeout=20)
|
||||
all_namespaced_resources = [r.strip() for r in namespaced_proc.stdout.strip().split('\n') if r.strip()]
|
||||
|
||||
# Check which resources actually exist in the namespace
|
||||
existing_resources = []
|
||||
seen_simple_names = set()
|
||||
|
||||
# print(f"[DEBUG] Checking {len(all_namespaced_resources)} resource types in namespace '{namespace}'...")
|
||||
|
||||
for resource_name in all_namespaced_resources:
|
||||
# Extract simple name (e.g., "deployments" from "deployments.apps")
|
||||
simple_name = resource_name.split('.')[0] if '.' in resource_name else resource_name
|
||||
|
||||
# Skip if already checked
|
||||
if simple_name in seen_simple_names:
|
||||
for line in proc.stdout.strip().splitlines():
|
||||
tokens = line.split()
|
||||
if len(tokens) < 4:
|
||||
continue
|
||||
|
||||
check_cmd = ["kubectl", "get", resource_name, "-n", namespace, "--no-headers", "--ignore-not-found"]
|
||||
|
||||
try:
|
||||
check_proc = subprocess.run(
|
||||
check_cmd,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, # Ignore errors
|
||||
text=True,
|
||||
timeout=2 # Short timeout per resource
|
||||
)
|
||||
|
||||
# If there's output and command succeeded, this resource exists
|
||||
if check_proc.returncode == 0 and check_proc.stdout.strip():
|
||||
is_common = simple_name in common_types
|
||||
existing_resources.append({
|
||||
"name": simple_name,
|
||||
"shortNames": [],
|
||||
"isCommon": is_common
|
||||
})
|
||||
seen_simple_names.add(simple_name)
|
||||
|
||||
except (subprocess.TimeoutExpired, Exception):
|
||||
# Skip problematic resources
|
||||
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)
|
||||
|
||||
# Sort: common types first, then alphabetically
|
||||
existing_resources.sort(key=lambda x: (not x['isCommon'], x['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 []
|
||||
|
||||
# print(f"[DEBUG] Found {len(existing_resources)} existing resources in namespace '{namespace}'")
|
||||
resources.append({
|
||||
"name": simple_name,
|
||||
"shortNames": short_names,
|
||||
"namespaced": namespaced,
|
||||
"isCommon": simple_name in COMMON_RESOURCE_TYPES,
|
||||
})
|
||||
|
||||
return existing_resources
|
||||
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
"kubectl is not installed or not in PATH. "
|
||||
"Please install kubectl to use this feature."
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(
|
||||
"Connection to Kubernetes cluster timed out while fetching 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)
|
||||
|
||||
if "connect: no route to host" in error_msg or "dial tcp" in error_msg or "Unable to connect" in error_msg:
|
||||
raise RuntimeError(
|
||||
"Unable to connect to Kubernetes cluster. "
|
||||
"Please ensure your cluster is running and accessible."
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"Failed to get resource types: {error_msg[:200]}")
|
||||
_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 getting resource types: {str(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,
|
||||
)
|
||||
|
||||
|
||||
# Use kubectl-diagrams directly (combines kubectl get + kube-diagrams in one command)
|
||||
def generate_from_cluster(
|
||||
resource_types: List[str],
|
||||
namespace: Optional[str] = None,
|
||||
resource_types: Optional[List[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.
|
||||
This is more efficient than calling kubectl get + kube-diagrams separately.
|
||||
"""
|
||||
"""Generate diagram using kubectl-diagrams plugin directly."""
|
||||
cmd: List[str] = []
|
||||
try:
|
||||
# Prepare resource types
|
||||
if not resource_types or len(resource_types) == 0:
|
||||
# Default resource types
|
||||
resource_types = ['pods', 'services', 'deployments', 'replicasets',
|
||||
'statefulsets', 'daemonsets', 'configmaps', 'secrets',
|
||||
'ingresses', 'persistentvolumeclaims']
|
||||
|
||||
# Join resource types as comma-separated list
|
||||
resources_arg = ','.join(resource_types)
|
||||
|
||||
# Generate unique output filename
|
||||
base_name = f"cluster-diagram-{uuid.uuid4().hex[:8]}"
|
||||
output_dir = "/tmp"
|
||||
output_file = f"{output_dir}/{base_name}.{output_format}"
|
||||
base_path = os.path.join(tempfile.gettempdir(), base_name)
|
||||
requested_output, png_output = FileManager.get_output_paths(base_path, output_format)
|
||||
|
||||
# Build kubectl-diagrams command
|
||||
cmd = ["kubectl-diagrams", resources_arg]
|
||||
|
||||
# Add namespace option
|
||||
if all_namespaces:
|
||||
cmd.append("--all-namespaces")
|
||||
elif namespace:
|
||||
cmd.extend(["-n", namespace])
|
||||
|
||||
# Add output file
|
||||
cmd.extend(["-o", output_file])
|
||||
cmd.extend(["-o", requested_output])
|
||||
|
||||
# Add format
|
||||
if output_format != "png":
|
||||
cmd.extend(["-f", output_format])
|
||||
|
||||
# Add without-namespace option
|
||||
if without_namespace:
|
||||
cmd.append("--without-namespace")
|
||||
|
||||
# Add extra args if provided
|
||||
if extra_args.strip():
|
||||
cmd.extend(parse_extra_args(extra_args))
|
||||
|
||||
# Execute command
|
||||
proc = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
|
||||
stdout_output = proc.stdout or ""
|
||||
stderr_output = proc.stderr or ""
|
||||
|
||||
# Check for errors
|
||||
if proc.returncode != 0 or has_fatal_error(stdout_output, stderr_output):
|
||||
# Check if it's a connection error
|
||||
if "Unable to connect" in stderr_output or "connect: no route to host" in stderr_output:
|
||||
return DiagramResult(
|
||||
success=False,
|
||||
error="Unable to connect to Kubernetes cluster. Please ensure your cluster is running.",
|
||||
command=" ".join(cmd),
|
||||
stdout=stdout_output,
|
||||
stderr=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="kubectl-diagrams failed. See command output below.",
|
||||
error=f"Output file not found: {requested_output}",
|
||||
command=" ".join(cmd),
|
||||
stdout=stdout_output,
|
||||
stderr=stderr_output
|
||||
)
|
||||
output_file, produced_format = output_info
|
||||
|
||||
# Check if output file exists
|
||||
if not os.path.exists(output_file):
|
||||
# Try with .png extension if original format didn't work
|
||||
png_output = f"{output_dir}/{base_name}.png"
|
||||
if os.path.exists(png_output):
|
||||
output_file = png_output
|
||||
produced_format = "png"
|
||||
else:
|
||||
return DiagramResult(
|
||||
success=False,
|
||||
error=f"Output file not found: {output_file}",
|
||||
command=" ".join(cmd),
|
||||
stdout=stdout_output,
|
||||
stderr=stderr_output
|
||||
)
|
||||
else:
|
||||
produced_format = output_format
|
||||
|
||||
# Read the file
|
||||
content = FileManager.read_file_content(output_file, binary=True)
|
||||
encoded = encode_content(content, produced_format)
|
||||
|
||||
# Clean up
|
||||
try:
|
||||
os.remove(output_file)
|
||||
except:
|
||||
pass
|
||||
FileManager.cleanup_files(output_file)
|
||||
|
||||
return DiagramResult(
|
||||
success=True,
|
||||
@@ -283,22 +238,20 @@ def generate_from_cluster(
|
||||
except FileNotFoundError:
|
||||
return DiagramResult(
|
||||
success=False,
|
||||
error="kubectl-diagrams is not installed or not in PATH. Please install kubectl-diagrams plugin.",
|
||||
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) if 'cmd' in locals() else "kubectl-diagrams"
|
||||
command=" ".join(cmd) or "kubectl-diagrams"
|
||||
)
|
||||
except Exception as e:
|
||||
return DiagramResult(
|
||||
success=False,
|
||||
error=f"Unexpected error: {str(e)}",
|
||||
command=" ".join(cmd) if 'cmd' in locals() else "kubectl-diagrams"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
command=" ".join(cmd) or "kubectl-diagrams"
|
||||
)
|
||||
@@ -1,23 +1,19 @@
|
||||
/**
|
||||
* Cluster Input Component
|
||||
* Handles cluster resource selection and diagram generation options
|
||||
* Stateless presentational component for cluster resource selection and diagram options.
|
||||
* All state and handlers are provided by ClusterTab/index.jsx via useClusterData.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Info, Server, RefreshCw, Layers } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Info, Server, RefreshCw, Layers, Search } from 'lucide-react';
|
||||
import SubmitButton from '../../common/SubmitButton.jsx';
|
||||
import ManifestOptions from '../../options/ManifestOptions';
|
||||
import { getClusterNamespaces, getClusterResourceTypes } from '../../../services/diagramApi.js';
|
||||
|
||||
function ClusterInput({
|
||||
// Input state (from ClusterTab/index.jsx)
|
||||
namespace,
|
||||
setNamespace,
|
||||
resourceTypes,
|
||||
setResourceTypes,
|
||||
allNamespaces,
|
||||
setAllNamespaces,
|
||||
outputFormat,
|
||||
setOutputFormat,
|
||||
extraArgs,
|
||||
@@ -25,127 +21,29 @@ function ClusterInput({
|
||||
withoutNamespace,
|
||||
setWithoutNamespace,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
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,
|
||||
}) {
|
||||
const [namespaces, setNamespaces] = useState([]);
|
||||
const [availableResourceTypes, setAvailableResourceTypes] = useState([]);
|
||||
const [loadingNamespaces, setLoadingNamespaces] = useState(false);
|
||||
const [loadingResourceTypes, setLoadingResourceTypes] = useState(false);
|
||||
|
||||
// Fetch namespaces
|
||||
useEffect(() => {
|
||||
fetchNamespaces();
|
||||
}, []);
|
||||
|
||||
// Fetch resource types when namespace changes
|
||||
useEffect(() => {
|
||||
if (namespace || allNamespaces) {
|
||||
fetchResourceTypes();
|
||||
}
|
||||
}, [namespace, allNamespaces]);
|
||||
|
||||
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')
|
||||
) {
|
||||
toast.error('Cluster not accessible', {
|
||||
description: errorMsg,
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: 'Help',
|
||||
onClick: () => {
|
||||
// Could open a modal with troubleshooting steps
|
||||
alert(
|
||||
'Please ensure your Kubernetes cluster is running:\n\n' +
|
||||
'- For minikube: run "minikube start"\n' +
|
||||
'Then refresh this page and try again.\n\n' +
|
||||
'See TROUBLESHOOTING_CLUSTER.md for more details.'
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to fetch namespaces', {
|
||||
description: errorMsg,
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
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 {
|
||||
// Pass the current namespace to get resources specific to it
|
||||
const response = await getClusterResourceTypes(allNamespaces ? null : namespace);
|
||||
if (response.ok && response.data?.resourceTypes) {
|
||||
setAvailableResourceTypes(response.data.resourceTypes);
|
||||
} else {
|
||||
const errorMsg = response.data?.error || 'Unknown error';
|
||||
|
||||
// Only show error if it's not a connection error (already shown for namespaces)
|
||||
if (!errorMsg.includes('Unable to connect') && !errorMsg.includes('not running')) {
|
||||
toast.error('Failed to fetch resource types', {
|
||||
description: errorMsg,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent fail to avoid duplicate error toasts
|
||||
console.error('Failed to fetch resource types:', error);
|
||||
} finally {
|
||||
setLoadingResourceTypes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResourceTypeToggle = (type) => {
|
||||
setResourceTypes((prev) => {
|
||||
if (prev.includes(type)) {
|
||||
return prev.filter((t) => t !== type);
|
||||
} else {
|
||||
return [...prev, type];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllCommon = () => {
|
||||
// Select all common resource types from the dynamic list
|
||||
const commonTypes = availableResourceTypes.filter((rt) => rt.isCommon).map((rt) => rt.name);
|
||||
setResourceTypes(commonTypes);
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setResourceTypes([]);
|
||||
};
|
||||
|
||||
const handleAllNamespacesToggle = (checked) => {
|
||||
setAllNamespaces(checked);
|
||||
if (checked) {
|
||||
setNamespace('');
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -153,6 +51,13 @@ function ClusterInput({
|
||||
<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">
|
||||
@@ -175,7 +80,7 @@ function ClusterInput({
|
||||
<label className="block text-sm font-medium text-white">Namespace</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchNamespaces}
|
||||
onClick={() => { fetchNamespaces(); fetchContext(); }}
|
||||
disabled={loadingNamespaces}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
|
||||
>
|
||||
@@ -186,10 +91,7 @@ function ClusterInput({
|
||||
<select
|
||||
className="w-full p-3 rounded-lg bg-gray-700 text-white"
|
||||
value={namespace}
|
||||
onChange={(e) => {
|
||||
setNamespace(e.target.value);
|
||||
if (errorMessage) setErrorMessage('');
|
||||
}}
|
||||
onChange={(e) => handleNamespaceChange(e.target.value)}
|
||||
disabled={loadingNamespaces || namespaces.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
@@ -220,155 +122,150 @@ function ClusterInput({
|
||||
|
||||
{/* Resource Types Selection */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<Layers className="inline w-4 h-4 mr-1" />
|
||||
Resource Types
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<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={handleSelectAllCommon}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
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={handleClearSelection}
|
||||
className="text-xs text-red-400 hover:text-red-300"
|
||||
onClick={handleSelectAll}
|
||||
disabled={loadingResourceTypes || availableResourceTypes.length === 0}
|
||||
className="text-blue-400 hover:text-blue-300 disabled:opacity-40"
|
||||
>
|
||||
Clear All
|
||||
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
|
||||
{namespace
|
||||
? ` for namespace "${namespace}"`
|
||||
: allNamespaces
|
||||
? ' from all namespaces'
|
||||
: ''}
|
||||
...
|
||||
</span>
|
||||
<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">
|
||||
{!namespace && !allNamespaces
|
||||
? 'Please select a namespace first to load available resource types.'
|
||||
: 'No resource types available. Please check your cluster connection.'}
|
||||
No resource types available. Please check your cluster connection.
|
||||
</p>
|
||||
{(namespace || allNamespaces) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchResourceTypes}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 mt-2"
|
||||
>
|
||||
<RefreshCw className="inline w-3 h-3 mr-1" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{/* Common Resource Types */}
|
||||
{availableResourceTypes.filter((rt) => rt.isCommon).length > 0 && (
|
||||
{commonVisible.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-semibold text-gray-400 mb-2 uppercase">
|
||||
Common Resources
|
||||
{namespace && (
|
||||
<span className="text-gray-500 font-normal ml-1">({namespace})</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableResourceTypes
|
||||
.filter((rt) => rt.isCommon)
|
||||
.map((rt) => (
|
||||
<label
|
||||
key={rt.name}
|
||||
className="flex items-center gap-2 text-sm text-white cursor-pointer hover:bg-gray-700 p-2 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={resourceTypes.includes(rt.name)}
|
||||
onChange={() => handleResourceTypeToggle(rt.name)}
|
||||
className="w-4 h-4 rounded bg-gray-700 border-gray-600"
|
||||
/>
|
||||
<span className="truncate" title={rt.name}>
|
||||
{rt.name}
|
||||
{rt.shortNames && rt.shortNames.length > 0 && (
|
||||
<span className="text-xs text-gray-500 ml-1">
|
||||
({rt.shortNames.join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{commonVisible.map((rt) => (
|
||||
<ResourceTypeItem
|
||||
key={rt.name}
|
||||
rt={rt}
|
||||
checked={resourceTypes.includes(rt.name)}
|
||||
onToggle={handleResourceTypeToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Resource Types */}
|
||||
{availableResourceTypes.filter((rt) => !rt.isCommon).length > 0 && (
|
||||
{otherVisible.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 mb-2 uppercase">
|
||||
Other Resources
|
||||
{namespace && (
|
||||
<span className="text-gray-500 font-normal ml-1">({namespace})</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableResourceTypes
|
||||
.filter((rt) => !rt.isCommon)
|
||||
.map((rt) => (
|
||||
<label
|
||||
key={rt.name}
|
||||
className="flex items-center gap-2 text-sm text-white cursor-pointer hover:bg-gray-700 p-2 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={resourceTypes.includes(rt.name)}
|
||||
onChange={() => handleResourceTypeToggle(rt.name)}
|
||||
className="w-4 h-4 rounded bg-gray-700 border-gray-600"
|
||||
/>
|
||||
<span className="truncate" title={rt.name}>
|
||||
{rt.name}
|
||||
{rt.shortNames && rt.shortNames.length > 0 && (
|
||||
<span className="text-xs text-gray-500 ml-1">
|
||||
({rt.shortNames.join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{otherVisible.map((rt) => (
|
||||
<ResourceTypeItem
|
||||
key={rt.name}
|
||||
rt={rt}
|
||||
checked={resourceTypes.includes(rt.name)}
|
||||
onToggle={handleResourceTypeToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Select resource types to include in the diagram. Leave empty for default selection.
|
||||
{(namespace || allNamespaces) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchResourceTypes}
|
||||
className="text-blue-400 hover:text-blue-300 ml-2"
|
||||
disabled={loadingResourceTypes}
|
||||
title="Refresh resource types"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`inline w-3 h-3 ${loadingResourceTypes ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
@@ -385,7 +282,7 @@ function ClusterInput({
|
||||
<SubmitButton
|
||||
onClick={onSubmit}
|
||||
className="w-full mt-2"
|
||||
disabled={isSubmitting || (!allNamespaces && !namespace)}
|
||||
disabled={isSubmitting || (!allNamespaces && !namespace) || resourceTypes.length === 0}
|
||||
>
|
||||
{isSubmitting ? 'Generating…' : 'Generate Cluster Diagram'}
|
||||
</SubmitButton>
|
||||
@@ -408,17 +305,24 @@ function ClusterInput({
|
||||
<span>
|
||||
{namespaces.length === 0 ? (
|
||||
<>
|
||||
<strong>Cluster not connected.</strong> Please start your Kubernetes cluster:
|
||||
<br />- For minikube:{' '}
|
||||
<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 /> Then click the Refresh button above. See{' '}
|
||||
<strong>TROUBLESHOOTING_CLUSTER.md</strong> for more help.
|
||||
<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.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will retrieve resources from your Kubernetes cluster using kubectl and
|
||||
generate a diagram. Make sure you have proper cluster access configured via
|
||||
kubeconfig.
|
||||
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>
|
||||
@@ -429,13 +333,49 @@ function ClusterInput({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
setNamespace: PropTypes.func.isRequired,
|
||||
resourceTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
setResourceTypes: PropTypes.func.isRequired,
|
||||
allNamespaces: PropTypes.bool.isRequired,
|
||||
setAllNamespaces: PropTypes.func.isRequired,
|
||||
outputFormat: PropTypes.string.isRequired,
|
||||
setOutputFormat: PropTypes.func.isRequired,
|
||||
extraArgs: PropTypes.string.isRequired,
|
||||
@@ -443,9 +383,27 @@ ClusterInput.propTypes = {
|
||||
withoutNamespace: PropTypes.bool.isRequired,
|
||||
setWithoutNamespace: PropTypes.func.isRequired,
|
||||
errorMessage: PropTypes.string,
|
||||
setErrorMessage: PropTypes.func.isRequired,
|
||||
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;
|
||||
export default ClusterInput;
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -34,7 +35,6 @@ function ClusterTab({ historyContext }) {
|
||||
setErrorMessage,
|
||||
isSubmitting,
|
||||
viewerKey,
|
||||
progressStep,
|
||||
handleSubmit: generateDiagram,
|
||||
resetOutput,
|
||||
} = useDiagramGeneration({
|
||||
@@ -61,9 +61,37 @@ function ClusterTab({ historyContext }) {
|
||||
}, [outputFormat, diagram, resetOutput]);
|
||||
|
||||
// Viewer synchronization hook for DOT_JSON format
|
||||
const { viewerRef, handleViewerLoad } = useViewerSync({
|
||||
diagram,
|
||||
outputFormat,
|
||||
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
|
||||
@@ -136,11 +164,8 @@ function ClusterTab({ historyContext }) {
|
||||
<div className="lg:w-1/4">
|
||||
<ClusterInput
|
||||
namespace={namespace}
|
||||
setNamespace={setNamespace}
|
||||
resourceTypes={resourceTypes}
|
||||
setResourceTypes={setResourceTypes}
|
||||
allNamespaces={allNamespaces}
|
||||
setAllNamespaces={setAllNamespaces}
|
||||
outputFormat={outputFormat}
|
||||
setOutputFormat={setOutputFormat}
|
||||
extraArgs={extraArgs}
|
||||
@@ -148,9 +173,27 @@ function ClusterTab({ historyContext }) {
|
||||
withoutNamespace={withoutNamespace}
|
||||
setWithoutNamespace={setWithoutNamespace}
|
||||
errorMessage={errorMessage}
|
||||
setErrorMessage={setErrorMessage}
|
||||
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>
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -199,6 +199,32 @@ export async function generateClusterDiagram({
|
||||
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
|
||||
@@ -239,19 +265,17 @@ export async function getClusterNamespaces() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of resource types from Kubernetes cluster
|
||||
* @param {string} [namespace] - Optional namespace filter
|
||||
* 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(namespace = '') {
|
||||
logger.debug('Fetching cluster resource types', { namespace });
|
||||
export async function getClusterResourceTypes() {
|
||||
logger.debug('Fetching cluster resource types');
|
||||
|
||||
try {
|
||||
const url = namespace
|
||||
? `${API_ENDPOINTS.CLUSTER_RESOURCE_TYPES}?namespace=${encodeURIComponent(namespace)}`
|
||||
: API_ENDPOINTS.CLUSTER_RESOURCE_TYPES;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(API_ENDPOINTS.CLUSTER_RESOURCE_TYPES, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -282,35 +306,6 @@ export async function getClusterResourceTypes(namespace = '') {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve resources from Kubernetes cluster
|
||||
* @param {Object} params - Request parameters
|
||||
* @param {string} [params.namespace] - Namespace filter
|
||||
* @param {Array<string>} [params.resourceTypes] - Resource types to retrieve
|
||||
* @param {boolean} [params.allNamespaces] - Retrieve from all namespaces
|
||||
* @returns {Promise<Object>} Response with manifest data
|
||||
*/
|
||||
export async function getClusterResources({
|
||||
namespace = '',
|
||||
resourceTypes = [],
|
||||
allNamespaces = false,
|
||||
}) {
|
||||
logger.debug('Retrieving cluster resources', { namespace, resourceTypes, allNamespaces });
|
||||
|
||||
const response = await apiFetch(API_ENDPOINTS.CLUSTER_RESOURCES, {
|
||||
body: JSON.stringify({
|
||||
namespace,
|
||||
resourceTypes,
|
||||
allNamespaces,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok && response.data.manifest) {
|
||||
logger.info('Cluster resources retrieved successfully');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit user feedback
|
||||
@@ -362,9 +357,9 @@ export default {
|
||||
generateHelmDiagram,
|
||||
generateHelmfileDiagram,
|
||||
generateClusterDiagram,
|
||||
getClusterContext,
|
||||
getClusterNamespaces,
|
||||
getClusterResourceTypes,
|
||||
getClusterResources,
|
||||
submitFeedback,
|
||||
hasFatalErrors,
|
||||
};
|
||||
|
||||
@@ -28,9 +28,9 @@ export const API_ENDPOINTS = Object.freeze({
|
||||
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',
|
||||
CLUSTER_RESOURCES: '/api/cluster/resources',
|
||||
SUBMIT_FEEDBACK: '/api/submit-feedback',
|
||||
EXAMPLES: '/api/examples',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user