[Feature] Refactor cluster tab with improved kubectl handling and stateless UI

This commit is contained in:
Sadallah
2026-06-11 00:03:17 +02:00
parent 55df49f6ab
commit 7e105eb79a
9 changed files with 699 additions and 500 deletions
+1 -1
View File
@@ -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."""
+22 -4
View File
@@ -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,
+3
View File
@@ -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',
]
+151 -198
View File
@@ -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>
+229
View File
@@ -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,
};
}
+34 -39
View File
@@ -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,
};
+1 -1
View File
@@ -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',
});