feat(kube): add Kubernetes management GUI components #71
BIN
new_banner.png
Normal file
BIN
new_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 MiB |
@ -1,6 +1,9 @@
|
|||||||
|
use crate::kube::portforward::PortForwardSessionConfig;
|
||||||
use crate::kube::ClusterClient;
|
use crate::kube::ClusterClient;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_yaml::Value;
|
||||||
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -37,14 +40,20 @@ pub async fn add_cluster(
|
|||||||
kubeconfig_content: String,
|
kubeconfig_content: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<ClusterInfo, String> {
|
) -> Result<ClusterInfo, String> {
|
||||||
|
if kubeconfig_content.trim().is_empty() {
|
||||||
|
return Err("Kubeconfig content cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let context = extract_context(&kubeconfig_content)?;
|
let context = extract_context(&kubeconfig_content)?;
|
||||||
let server_url = extract_server_url(&kubeconfig_content)?;
|
let server_url = extract_server_url(&kubeconfig_content)?;
|
||||||
|
|
||||||
|
let kubeconfig_arc = Arc::new(kubeconfig_content.clone());
|
||||||
let client = ClusterClient::new(
|
let client = ClusterClient::new(
|
||||||
id.clone(),
|
id.clone(),
|
||||||
name.clone(),
|
name.clone(),
|
||||||
context.clone(),
|
context.clone(),
|
||||||
server_url.clone(),
|
server_url.clone(),
|
||||||
|
kubeconfig_arc,
|
||||||
);
|
);
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -60,6 +69,49 @@ pub async fn add_cluster(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_context(content: &str) -> Result<String, String> {
|
||||||
|
let value: Value =
|
||||||
|
serde_yaml::from_str(content).map_err(|e| format!("Invalid kubeconfig YAML: {}", e))?;
|
||||||
|
|
||||||
|
let contexts = value
|
||||||
|
.get("contexts")
|
||||||
|
.and_then(|c| c.as_sequence())
|
||||||
|
.ok_or("Missing 'contexts' field in kubeconfig")?;
|
||||||
|
|
||||||
|
if contexts.is_empty() {
|
||||||
|
return Err("No contexts found in kubeconfig".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_context = contexts[0].get("name").and_then(|n| n.as_str());
|
||||||
|
first_context
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| "Context name not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_server_url(content: &str) -> Result<String, String> {
|
||||||
|
let value: Value =
|
||||||
|
serde_yaml::from_str(content).map_err(|e| format!("Invalid kubeconfig YAML: {}", e))?;
|
||||||
|
|
||||||
|
let clusters = value
|
||||||
|
.get("clusters")
|
||||||
|
.and_then(|c| c.as_sequence())
|
||||||
|
.ok_or("Missing 'clusters' field in kubeconfig")?;
|
||||||
|
|
||||||
|
if clusters.is_empty() {
|
||||||
|
return Err("No clusters found in kubeconfig".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cluster = &clusters[0];
|
||||||
|
let server = cluster
|
||||||
|
.get("cluster")
|
||||||
|
.and_then(|c| c.get("server"))
|
||||||
|
.and_then(|s| s.as_str());
|
||||||
|
|
||||||
|
server
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| "Server URL not found in cluster".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> {
|
pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
let mut clusters = state.clusters.lock().await;
|
let mut clusters = state.clusters.lock().await;
|
||||||
@ -95,15 +147,24 @@ pub async fn start_port_forward(
|
|||||||
) -> Result<PortForwardResponse, String> {
|
) -> Result<PortForwardResponse, String> {
|
||||||
let session_id = uuid::Uuid::now_v7().to_string();
|
let session_id = uuid::Uuid::now_v7().to_string();
|
||||||
|
|
||||||
let session = crate::kube::PortForwardSession::new(
|
let clusters = state.clusters.lock().await;
|
||||||
session_id.clone(),
|
let cluster = clusters
|
||||||
request.cluster_id.clone(),
|
.get(&request.cluster_id)
|
||||||
request.namespace.clone(),
|
.ok_or_else(|| format!("Cluster {} not found", request.cluster_id))?;
|
||||||
request.pod.clone(),
|
|
||||||
None,
|
let cluster_name = cluster.name.clone();
|
||||||
vec![request.container_port],
|
let _kubeconfig_content = cluster.kubeconfig_content.clone();
|
||||||
vec![0],
|
|
||||||
);
|
let session = crate::kube::PortForwardSession::new(PortForwardSessionConfig {
|
||||||
|
id: session_id.clone(),
|
||||||
|
cluster_id: request.cluster_id.clone(),
|
||||||
|
cluster_name,
|
||||||
|
namespace: request.namespace.clone(),
|
||||||
|
pod: request.pod.clone(),
|
||||||
|
container: None,
|
||||||
|
ports: vec![request.container_port],
|
||||||
|
local_ports: vec![0],
|
||||||
|
});
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut port_forwards = state.port_forwards.lock().await;
|
let mut port_forwards = state.port_forwards.lock().await;
|
||||||
@ -159,10 +220,13 @@ pub async fn list_port_forwards(
|
|||||||
Ok(forwards)
|
Ok(forwards)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_context(_content: &str) -> Result<String, String> {
|
#[tauri::command]
|
||||||
Ok("default".to_string())
|
pub async fn delete_port_forward(id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
}
|
let mut port_forwards = state.port_forwards.lock().await;
|
||||||
|
|
||||||
fn extract_server_url(_content: &str) -> Result<String, String> {
|
if port_forwards.remove(&id).is_none() {
|
||||||
Ok("unknown".to_string())
|
return Err(format!("Port forward session {id} not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,27 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct ClusterClient {
|
pub struct ClusterClient {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub context: String,
|
pub context: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
pub kubeconfig_content: Arc<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClusterClient {
|
impl ClusterClient {
|
||||||
pub fn new(id: String, name: String, context: String, server_url: String) -> Self {
|
pub fn new(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
context: String,
|
||||||
|
server_url: String,
|
||||||
|
kubeconfig_content: Arc<String>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
context,
|
context,
|
||||||
server_url,
|
server_url,
|
||||||
|
kubeconfig_content,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct PortForwardSession {
|
pub struct PortForwardSession {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub cluster_id: String,
|
pub cluster_id: String,
|
||||||
|
pub cluster_name: String,
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
pub pod: String,
|
pub pod: String,
|
||||||
pub container: Option<String>,
|
pub container: Option<String>,
|
||||||
pub ports: Vec<u16>,
|
pub ports: Vec<u16>,
|
||||||
pub local_ports: Vec<u16>,
|
pub local_ports: Vec<u16>,
|
||||||
pub status: PortForwardStatus,
|
pub status: PortForwardStatus,
|
||||||
|
pub kubectl_child: Option<Arc<std::sync::Mutex<std::process::Child>>>,
|
||||||
|
pub is_stopped: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum PortForwardStatus {
|
pub enum PortForwardStatus {
|
||||||
@ -15,33 +21,59 @@ pub enum PortForwardStatus {
|
|||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PortForwardSessionConfig {
|
||||||
|
pub id: String,
|
||||||
|
pub cluster_id: String,
|
||||||
|
pub cluster_name: String,
|
||||||
|
pub namespace: String,
|
||||||
|
pub pod: String,
|
||||||
|
pub container: Option<String>,
|
||||||
|
pub ports: Vec<u16>,
|
||||||
|
pub local_ports: Vec<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
impl PortForwardSession {
|
impl PortForwardSession {
|
||||||
pub fn new(
|
pub fn new(config: PortForwardSessionConfig) -> Self {
|
||||||
id: String,
|
|
||||||
cluster_id: String,
|
|
||||||
namespace: String,
|
|
||||||
pod: String,
|
|
||||||
container: Option<String>,
|
|
||||||
ports: Vec<u16>,
|
|
||||||
local_ports: Vec<u16>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
id,
|
id: config.id,
|
||||||
cluster_id,
|
cluster_id: config.cluster_id,
|
||||||
namespace,
|
cluster_name: config.cluster_name,
|
||||||
pod,
|
namespace: config.namespace,
|
||||||
container,
|
pod: config.pod,
|
||||||
ports,
|
container: config.container,
|
||||||
local_ports,
|
ports: config.ports,
|
||||||
|
local_ports: config.local_ports,
|
||||||
status: PortForwardStatus::Active,
|
status: PortForwardStatus::Active,
|
||||||
|
kubectl_child: None,
|
||||||
|
is_stopped: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
|
self.is_stopped.store(true, Ordering::SeqCst);
|
||||||
self.status = PortForwardStatus::Stopped;
|
self.status = PortForwardStatus::Stopped;
|
||||||
|
|
||||||
|
if let Some(child_mutex) = &self.kubectl_child {
|
||||||
|
let mut child = child_mutex.lock().unwrap();
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_active(&self) -> bool {
|
pub fn is_active(&self) -> bool {
|
||||||
matches!(self.status, PortForwardStatus::Active)
|
matches!(self.status, PortForwardStatus::Active)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for PortForwardSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.is_stopped.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(child_mutex) = &self.kubectl_child {
|
||||||
|
let mut child = child_mutex.lock().unwrap();
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -181,6 +181,7 @@ pub fn run() {
|
|||||||
commands::kube::start_port_forward,
|
commands::kube::start_port_forward,
|
||||||
commands::kube::stop_port_forward,
|
commands::kube::stop_port_forward,
|
||||||
commands::kube::list_port_forwards,
|
commands::kube::list_port_forwards,
|
||||||
|
commands::kube::delete_port_forward,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("Error running Troubleshooting and RCA Assistant application");
|
.expect("Error running Troubleshooting and RCA Assistant application");
|
||||||
|
|||||||
130
src/components/Kubernetes/AddClusterModal.tsx
Normal file
130
src/components/Kubernetes/AddClusterModal.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { X, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import type { ClusterInfo } from "@/lib/tauriCommands";
|
||||||
|
import { addClusterCmd } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface AddClusterModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (cluster: ClusterInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddClusterModal({ isOpen, onClose, onAdd }: AddClusterModalProps) {
|
||||||
|
const [id, setId] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [kubeconfig, setKubeconfig] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (!id.trim() || !name.trim() || !kubeconfig.trim()) {
|
||||||
|
setError("All fields are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cluster = await addClusterCmd(id, name, kubeconfig);
|
||||||
|
onAdd(cluster);
|
||||||
|
onClose();
|
||||||
|
setId("");
|
||||||
|
setName("");
|
||||||
|
setKubeconfig("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to add cluster");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-2xl rounded-lg border bg-background shadow-lg">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h3 className="text-lg font-semibold">Add Kubernetes Cluster</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/15 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
Cluster ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={id}
|
||||||
|
onChange={(e) => setId(e.target.value)}
|
||||||
|
placeholder="e.g., prod-cluster-01"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
Cluster Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Production Cluster"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
Kubeconfig Content
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={kubeconfig}
|
||||||
|
onChange={(e) => setKubeconfig(e.target.value)}
|
||||||
|
placeholder="Paste your kubeconfig YAML here..."
|
||||||
|
rows={10}
|
||||||
|
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Paste the contents of your kubeconfig file (YAML format)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Add Cluster
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/Kubernetes/ClusterList.tsx
Normal file
79
src/components/Kubernetes/ClusterList.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Trash2, Plus, Server, Activity } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import type { ClusterInfo } from "@/lib/tauriCommands";
|
||||||
|
import { removeClusterCmd } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface ClusterListProps {
|
||||||
|
clusters: ClusterInfo[];
|
||||||
|
onAdd: () => void;
|
||||||
|
onRemove: (clusterId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClusterList({ clusters, onAdd, onRemove }: ClusterListProps) {
|
||||||
|
const handleRemove = async (clusterId: string) => {
|
||||||
|
if (window.confirm("Are you sure you want to remove this cluster?")) {
|
||||||
|
await onRemove(clusterId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="w-5 h-5 text-muted-foreground" />
|
||||||
|
<h2 className="text-lg font-semibold">Clusters</h2>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Cluster
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clusters.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed px-6 py-12 text-center">
|
||||||
|
<Server className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No clusters configured</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Add a Kubernetes cluster to start managing it
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={onAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Your First Cluster
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{clusters.map((cluster) => (
|
||||||
|
<div
|
||||||
|
key={cluster.id}
|
||||||
|
className="rounded-lg border bg-card p-4 hover:border-primary/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-lg">{cluster.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-mono">
|
||||||
|
ID: {cluster.id}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Context: {cluster.context}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground font-mono break-all">
|
||||||
|
URL: {cluster.cluster_url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemove(cluster.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
src/components/Kubernetes/PortForwardForm.tsx
Normal file
185
src/components/Kubernetes/PortForwardForm.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { X, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import type { PortForwardResponse } from "@/lib/tauriCommands";
|
||||||
|
import { startPortForwardCmd } from "@/lib/tauriCommands";
|
||||||
|
import { listClustersCmd } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface PortForwardFormProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onStart: (portForward: PortForwardResponse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortForwardForm({ isOpen, onClose, onStart }: PortForwardFormProps) {
|
||||||
|
const [clusterId, setClusterId] = useState("");
|
||||||
|
const [namespace, setNamespace] = useState("default");
|
||||||
|
const [pod, setPod] = useState("");
|
||||||
|
const [containerPort, setContainerPort] = useState<string>("80");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [clusters, setClusters] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadClusters();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const loadClusters = async () => {
|
||||||
|
try {
|
||||||
|
const clusters = await listClustersCmd();
|
||||||
|
setClusters(clusters.map((c) => ({ id: c.id, name: c.name })));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load clusters:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (!clusterId || !namespace || !pod || !containerPort) {
|
||||||
|
setError("All fields are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerPort.trim() === "") {
|
||||||
|
setError("Container port is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = parseInt(containerPort, 10);
|
||||||
|
if (isNaN(port) || port < 1 || port > 65535) {
|
||||||
|
setError("Container port must be a valid port number (1-65535)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const portForward = await startPortForwardCmd({
|
||||||
|
cluster_id: clusterId,
|
||||||
|
namespace,
|
||||||
|
pod,
|
||||||
|
container_port: port,
|
||||||
|
});
|
||||||
|
onStart(portForward);
|
||||||
|
onClose();
|
||||||
|
setClusterId("");
|
||||||
|
setNamespace("default");
|
||||||
|
setPod("");
|
||||||
|
setContainerPort("80");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to start port forward");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-lg rounded-lg border bg-background shadow-lg">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h3 className="text-lg font-semibold">Start Port Forward</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/15 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
Cluster
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={clusterId}
|
||||||
|
onChange={(e) => setClusterId(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select a cluster
|
||||||
|
</option>
|
||||||
|
{clusters.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
Namespace
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={namespace}
|
||||||
|
onChange={(e) => setNamespace(e.target.value)}
|
||||||
|
placeholder="default"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
Pod Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pod}
|
||||||
|
onChange={(e) => setPod(e.target.value)}
|
||||||
|
placeholder="e.g., nginx-abc123"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
Container Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={containerPort}
|
||||||
|
onChange={(e) => setContainerPort(e.target.value)}
|
||||||
|
placeholder="80"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Start Port Forward
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/Kubernetes/PortForwardList.tsx
Normal file
128
src/components/Kubernetes/PortForwardList.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Trash2, Plus, Activity } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import type { PortForwardResponse } from "@/lib/tauriCommands";
|
||||||
|
import { stopPortForwardCmd } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface PortForwardListProps {
|
||||||
|
portForwards: PortForwardResponse[];
|
||||||
|
onStart: () => void;
|
||||||
|
onStop: (id: string) => Promise<void>;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortForwardList({ portForwards, onStart, onStop, onDelete }: PortForwardListProps) {
|
||||||
|
const handleStop = async (id: string) => {
|
||||||
|
if (window.confirm("Are you sure you want to stop this port forward?")) {
|
||||||
|
await onStop(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (window.confirm("Are you sure you want to delete this port forward? This cannot be undone.")) {
|
||||||
|
await onDelete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const statusLower = status.toLowerCase().trim();
|
||||||
|
if (statusLower === "") {
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
switch (statusLower) {
|
||||||
|
case "active":
|
||||||
|
return "bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20";
|
||||||
|
case "stopped":
|
||||||
|
return "bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20";
|
||||||
|
case "error":
|
||||||
|
return "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5 text-muted-foreground" />
|
||||||
|
<h2 className="text-lg font-semibold">Port Forwards</h2>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onStart}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Start Port Forward
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{portForwards.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed px-6 py-12 text-center">
|
||||||
|
<Activity className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No active port forwards</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Start a port forward to expose a pod locally
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={onStart}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Start Your First Port Forward
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{portForwards.map((pf) => (
|
||||||
|
<div
|
||||||
|
key={pf.id}
|
||||||
|
className="rounded-lg border bg-card p-4 hover:border-primary/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-lg">Port Forward</h3>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border ${getStatusColor(
|
||||||
|
pf.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{pf.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Cluster: {pf.cluster_id}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Namespace: {pf.namespace}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Pod: {pf.pod}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>Container Port: {pf.container_port}</span>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span>Local Port: {pf.local_port > 0 ? pf.local_port : "pending"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{pf.status.toLowerCase() === "active" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStop(pf.id)}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(pf.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/components/Kubernetes/index.tsx
Normal file
4
src/components/Kubernetes/index.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { ClusterList } from "./ClusterList";
|
||||||
|
export { PortForwardList } from "./PortForwardList";
|
||||||
|
export { AddClusterModal } from "./AddClusterModal";
|
||||||
|
export { PortForwardForm } from "./PortForwardForm";
|
||||||
@ -738,3 +738,52 @@ export const listCommandExecutionsCmd = (issueId?: string) =>
|
|||||||
|
|
||||||
export const checkKubectlInstalledCmd = () =>
|
export const checkKubectlInstalledCmd = () =>
|
||||||
invoke<KubectlStatus>("check_kubectl_installed");
|
invoke<KubectlStatus>("check_kubectl_installed");
|
||||||
|
|
||||||
|
// ─── Kubernetes Management Types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ClusterInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
context: string;
|
||||||
|
cluster_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortForwardRequest {
|
||||||
|
cluster_id: string;
|
||||||
|
namespace: string;
|
||||||
|
pod: string;
|
||||||
|
container_port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortForwardResponse {
|
||||||
|
id: string;
|
||||||
|
cluster_id: string;
|
||||||
|
namespace: string;
|
||||||
|
pod: string;
|
||||||
|
container_port: number;
|
||||||
|
local_port: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Kubernetes Management Commands ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export const addClusterCmd = (id: string, name: string, kubeconfigContent: string) =>
|
||||||
|
invoke<ClusterInfo>("add_cluster", { id, name, kubeconfig_content: kubeconfigContent });
|
||||||
|
|
||||||
|
export const removeClusterCmd = (id: string) =>
|
||||||
|
invoke<void>("remove_cluster", { id });
|
||||||
|
|
||||||
|
export const listClustersCmd = () =>
|
||||||
|
invoke<ClusterInfo[]>("list_clusters");
|
||||||
|
|
||||||
|
export const startPortForwardCmd = (request: PortForwardRequest) =>
|
||||||
|
invoke<PortForwardResponse>("start_port_forward", { request });
|
||||||
|
|
||||||
|
export const stopPortForwardCmd = (id: string) =>
|
||||||
|
invoke<void>("stop_port_forward", { id });
|
||||||
|
|
||||||
|
export const deletePortForwardCmd = (id: string) =>
|
||||||
|
invoke<void>("delete_port_forward", { id });
|
||||||
|
|
||||||
|
export const listPortForwardsCmd = () =>
|
||||||
|
invoke<PortForwardResponse[]>("list_port_forwards");
|
||||||
|
|||||||
129
src/pages/Kubernetes/KubernetesPage.tsx
Normal file
129
src/pages/Kubernetes/KubernetesPage.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Server, Activity } from "lucide-react";
|
||||||
|
import { ClusterList } from "@/components/Kubernetes/ClusterList";
|
||||||
|
import { PortForwardList } from "@/components/Kubernetes/PortForwardList";
|
||||||
|
import { AddClusterModal } from "@/components/Kubernetes/AddClusterModal";
|
||||||
|
import { PortForwardForm } from "@/components/Kubernetes/PortForwardForm";
|
||||||
|
import type { ClusterInfo, PortForwardResponse } from "@/lib/tauriCommands";
|
||||||
|
import {
|
||||||
|
listClustersCmd,
|
||||||
|
removeClusterCmd,
|
||||||
|
listPortForwardsCmd,
|
||||||
|
stopPortForwardCmd,
|
||||||
|
deletePortForwardCmd,
|
||||||
|
} from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
export function KubernetesPage() {
|
||||||
|
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
|
||||||
|
const [portForwards, setPortForwards] = useState<PortForwardResponse[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isAddClusterOpen, setIsAddClusterOpen] = useState(false);
|
||||||
|
const [isStartPortForwardOpen, setIsStartPortForwardOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [clustersData, portForwardsData] = await Promise.all([
|
||||||
|
listClustersCmd(),
|
||||||
|
listPortForwardsCmd(),
|
||||||
|
]);
|
||||||
|
setClusters(clustersData);
|
||||||
|
setPortForwards(portForwardsData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load data:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCluster = async (clusterId: string) => {
|
||||||
|
try {
|
||||||
|
await removeClusterCmd(clusterId);
|
||||||
|
setClusters((prev) => prev.filter((c) => c.id !== clusterId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to remove cluster:", err);
|
||||||
|
alert("Failed to remove cluster");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopPortForward = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await stopPortForwardCmd(id);
|
||||||
|
setPortForwards((prev) => prev.filter((pf) => pf.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to stop port forward:", err);
|
||||||
|
alert("Failed to stop port forward");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePortForward = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deletePortForwardCmd(id);
|
||||||
|
setPortForwards((prev) => prev.filter((pf) => pf.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete port forward:", err);
|
||||||
|
alert("Failed to delete port forward");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCluster = (cluster: ClusterInfo) => {
|
||||||
|
setClusters((prev) => [...prev, cluster]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartPortForward = (portForward: PortForwardResponse) => {
|
||||||
|
setPortForwards((prev) => [...prev, portForward]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
<p className="text-muted-foreground">Loading Kubernetes resources...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-6 space-y-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Kubernetes Management</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your Kubernetes clusters and port forwarding sessions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8">
|
||||||
|
<ClusterList
|
||||||
|
clusters={clusters}
|
||||||
|
onAdd={() => setIsAddClusterOpen(true)}
|
||||||
|
onRemove={handleRemoveCluster}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PortForwardList
|
||||||
|
portForwards={portForwards}
|
||||||
|
onStart={() => setIsStartPortForwardOpen(true)}
|
||||||
|
onStop={handleStopPortForward}
|
||||||
|
onDelete={handleDeletePortForward}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddClusterModal
|
||||||
|
isOpen={isAddClusterOpen}
|
||||||
|
onClose={() => setIsAddClusterOpen(false)}
|
||||||
|
onAdd={handleAddCluster}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PortForwardForm
|
||||||
|
isOpen={isStartPortForwardOpen}
|
||||||
|
onClose={() => setIsStartPortForwardOpen(false)}
|
||||||
|
onStart={handleStartPortForward}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
tests/unit/kubernetesCommands.test.ts
Normal file
141
tests/unit/kubernetesCommands.test.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import * as tauriCommands from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
// Mock Tauri invoke
|
||||||
|
vi.mock("@tauri-apps/api/core");
|
||||||
|
|
||||||
|
type MockedFunction<T = (...args: any[]) => any> = T & {
|
||||||
|
mockResolvedValue: (value: any) => void;
|
||||||
|
mockRejectedValue: (error: Error) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Kubernetes Management Commands", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addClusterCmd", () => {
|
||||||
|
it("should call invoke with correct parameters", async () => {
|
||||||
|
(invoke as MockedFunction).mockResolvedValue({
|
||||||
|
id: "cluster-1",
|
||||||
|
name: "production",
|
||||||
|
context: "prod-context",
|
||||||
|
cluster_url: "https://prod.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tauriCommands.addClusterCmd(
|
||||||
|
"cluster-1",
|
||||||
|
"production",
|
||||||
|
"kubeconfig-content"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("add_cluster", {
|
||||||
|
id: "cluster-1",
|
||||||
|
name: "production",
|
||||||
|
kubeconfig_content: "kubeconfig-content",
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "cluster-1",
|
||||||
|
name: "production",
|
||||||
|
context: "prod-context",
|
||||||
|
cluster_url: "https://prod.example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeClusterCmd", () => {
|
||||||
|
it("should call invoke with cluster id", async () => {
|
||||||
|
(invoke as MockedFunction).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await tauriCommands.removeClusterCmd("cluster-1");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("remove_cluster", { id: "cluster-1" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listClustersCmd", () => {
|
||||||
|
it("should call invoke and return cluster list", async () => {
|
||||||
|
(invoke as MockedFunction).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "cluster-1",
|
||||||
|
name: "production",
|
||||||
|
context: "prod-context",
|
||||||
|
cluster_url: "https://prod.example.com",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await tauriCommands.listClustersCmd();
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("list_clusters");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe("production");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startPortForwardCmd", () => {
|
||||||
|
it("should call invoke with port forward request", async () => {
|
||||||
|
(invoke as MockedFunction).mockResolvedValue({
|
||||||
|
id: "pf-1",
|
||||||
|
cluster_id: "cluster-1",
|
||||||
|
namespace: "default",
|
||||||
|
pod: "nginx-abc123",
|
||||||
|
container_port: 80,
|
||||||
|
local_port: 8080,
|
||||||
|
status: "Active",
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
cluster_id: "cluster-1",
|
||||||
|
namespace: "default",
|
||||||
|
pod: "nginx-abc123",
|
||||||
|
container_port: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await tauriCommands.startPortForwardCmd(request);
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("start_port_forward", { request });
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "pf-1",
|
||||||
|
cluster_id: "cluster-1",
|
||||||
|
namespace: "default",
|
||||||
|
pod: "nginx-abc123",
|
||||||
|
container_port: 80,
|
||||||
|
local_port: 8080,
|
||||||
|
status: "Active",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stopPortForwardCmd", () => {
|
||||||
|
it("should call invoke with session id", async () => {
|
||||||
|
(invoke as MockedFunction).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await tauriCommands.stopPortForwardCmd("pf-1");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("stop_port_forward", { id: "pf-1" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listPortForwardsCmd", () => {
|
||||||
|
it("should call invoke and return port forwards list", async () => {
|
||||||
|
(invoke as MockedFunction).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "pf-1",
|
||||||
|
cluster_id: "cluster-1",
|
||||||
|
namespace: "default",
|
||||||
|
pod: "nginx-abc123",
|
||||||
|
container_port: 80,
|
||||||
|
local_port: 8080,
|
||||||
|
status: "Active",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await tauriCommands.listPortForwardsCmd();
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("list_port_forwards");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].pod).toBe("nginx-abc123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user