feat(kube): add Kubernetes management GUI components

- Add ClusterList, PortForwardList, AddClusterModal, PortForwardForm components
- Add KubernetesPage component with cluster and port forward management
- Add TypeScript types for Kubernetes management (ClusterInfo, PortForwardRequest, PortForwardResponse)
- Add 6 IPC commands to tauriCommands.ts for cluster and port forward management
- Write unit tests for Kubernetes IPC commands (6 tests)
- All 308 Rust tests passing
- All 98 frontend tests passing
- TypeScript type check passing
- Project builds successfully
This commit is contained in:
Shaun Arman 2026-06-06 12:46:33 -05:00
parent 94d88d25dc
commit f5fb9bd0e2
8 changed files with 814 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,180 @@
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 || !pod || !containerPort) {
setError("All fields are 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>
);
}

View File

@ -0,0 +1,117 @@
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: (session_id: string) => Promise<void>;
}
export function PortForwardList({ portForwards, onStart, onStop }: PortForwardListProps) {
const handleStop = async (id: string) => {
if (window.confirm("Are you sure you want to stop this port forward?")) {
await onStop(id);
}
};
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
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={() => handleStop(pf.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,4 @@
export { ClusterList } from "./ClusterList";
export { PortForwardList } from "./PortForwardList";
export { AddClusterModal } from "./AddClusterModal";
export { PortForwardForm } from "./PortForwardForm";

View File

@ -738,3 +738,49 @@ export const listCommandExecutionsCmd = (issueId?: string) =>
export const checkKubectlInstalledCmd = () =>
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 listPortForwardsCmd = () =>
invoke<PortForwardResponse[]>("list_port_forwards");

View File

@ -0,0 +1,117 @@
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,
} 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 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}
/>
</div>
<AddClusterModal
isOpen={isAddClusterOpen}
onClose={() => setIsAddClusterOpen(false)}
onAdd={handleAddCluster}
/>
<PortForwardForm
isOpen={isStartPortForwardOpen}
onClose={() => setIsStartPortForwardOpen(false)}
onStart={handleStartPortForward}
/>
</div>
);
}

View 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");
});
});
});