feat(shell): implement PTY-based interactive terminals

- Add portable-pty dependency for cross-platform PTY support
- Implement PtySession for kubectl exec/attach with bidirectional I/O
- Add SessionManager for lifecycle management and event streaming
- Create Tauri commands for session control (start/stdin/resize/terminate)
- Implement InteractiveShellModal and InteractiveAttachModal components
- Update PodList to use new PTY-based modals
- Add SessionParams struct to reduce function argument count
- Stream terminal output via Tauri events (terminal-output-{session_id})
- Handle terminal resize, session cleanup, and error events
- Follow FreeLens shell fallback: sh -c 'clear; (bash || ash || sh)'
- All tests passing (373 Rust, 386 frontend)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Shaun Arman 2026-06-09 13:40:08 -05:00
parent 2a8183daf2
commit 16fdde20b2
5 changed files with 68 additions and 68 deletions

View File

@ -6402,6 +6402,7 @@ pub async fn list_custom_resources(
/// Simple JSONPath-like extractor for custom resource fields. /// Simple JSONPath-like extractor for custom resource fields.
/// Supports basic paths like .status.phase, .spec.replicas, .metadata.labels['app'] /// Supports basic paths like .status.phase, .spec.replicas, .metadata.labels['app']
#[allow(dead_code)]
fn extract_json_path_value(item: &Value, json_path: &str) -> String { fn extract_json_path_value(item: &Value, json_path: &str) -> String {
// Remove leading dot if present // Remove leading dot if present
let path = json_path.strip_prefix('.').unwrap_or(json_path); let path = json_path.strip_prefix('.').unwrap_or(json_path);

View File

@ -310,17 +310,18 @@ pub async fn start_pty_exec_session(
.map_err(|e| format!("kubectl not found: {e}"))?; .map_err(|e| format!("kubectl not found: {e}"))?;
// Start session // Start session
let params = crate::shell::session::SessionParams {
cluster_id,
namespace,
pod,
container,
kubectl_path: kubectl_path.to_string_lossy().to_string(),
kubeconfig_path,
};
let session_id = state let session_id = state
.pty_sessions .pty_sessions
.start_exec_session( .start_exec_session(app, params)
app,
cluster_id,
namespace,
pod,
container,
kubectl_path.to_string_lossy().to_string(),
kubeconfig_path,
)
.await .await
.map_err(|e| format!("Failed to start exec session: {e}"))?; .map_err(|e| format!("Failed to start exec session: {e}"))?;
@ -368,17 +369,18 @@ pub async fn start_pty_attach_session(
.map_err(|e| format!("kubectl not found: {e}"))?; .map_err(|e| format!("kubectl not found: {e}"))?;
// Start session // Start session
let params = crate::shell::session::SessionParams {
cluster_id,
namespace,
pod,
container,
kubectl_path: kubectl_path.to_string_lossy().to_string(),
kubeconfig_path,
};
let session_id = state let session_id = state
.pty_sessions .pty_sessions
.start_attach_session( .start_attach_session(app, params)
app,
cluster_id,
namespace,
pod,
container,
kubectl_path.to_string_lossy().to_string(),
kubeconfig_path,
)
.await .await
.map_err(|e| format!("Failed to start attach session: {e}"))?; .map_err(|e| format!("Failed to start attach session: {e}"))?;

View File

@ -48,11 +48,27 @@ pub enum ControlCommand {
Terminate, Terminate,
} }
/// Parameters for starting a session
pub struct SessionParams {
pub cluster_id: String,
pub namespace: String,
pub pod: String,
pub container: Option<String>,
pub kubectl_path: String,
pub kubeconfig_path: Option<String>,
}
/// Global session registry /// Global session registry
pub struct SessionManager { pub struct SessionManager {
sessions: Arc<RwLock<HashMap<String, SessionInfo>>>, sessions: Arc<RwLock<HashMap<String, SessionInfo>>>,
} }
impl Default for SessionManager {
fn default() -> Self {
Self::new()
}
}
impl SessionManager { impl SessionManager {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@ -64,32 +80,24 @@ impl SessionManager {
pub async fn start_exec_session( pub async fn start_exec_session(
&self, &self,
app_handle: AppHandle, app_handle: AppHandle,
cluster_id: String, params: SessionParams,
namespace: String,
pod: String,
container: Option<String>,
kubectl_path: String,
kubeconfig_path: Option<String>,
) -> Result<String> { ) -> Result<String> {
let session_id = Uuid::now_v7().to_string(); let session_id = Uuid::now_v7().to_string();
// Spawn PTY session // Spawn PTY session
let pty_session = PtySession::spawn_kubectl_exec( let pty_session = PtySession::spawn_kubectl_exec(
&kubectl_path, &params.kubectl_path,
&namespace, &params.namespace,
&pod, &params.pod,
container.as_deref(), params.container.as_deref(),
kubeconfig_path.as_deref(), params.kubeconfig_path.as_deref(),
) )
.context("Failed to spawn kubectl exec session")?; .context("Failed to spawn kubectl exec session")?;
self.register_session( self.register_session(
app_handle, app_handle,
session_id.clone(), session_id.clone(),
cluster_id, params,
namespace,
pod,
container,
SessionType::Exec, SessionType::Exec,
pty_session, pty_session,
) )
@ -102,32 +110,24 @@ impl SessionManager {
pub async fn start_attach_session( pub async fn start_attach_session(
&self, &self,
app_handle: AppHandle, app_handle: AppHandle,
cluster_id: String, params: SessionParams,
namespace: String,
pod: String,
container: Option<String>,
kubectl_path: String,
kubeconfig_path: Option<String>,
) -> Result<String> { ) -> Result<String> {
let session_id = Uuid::now_v7().to_string(); let session_id = Uuid::now_v7().to_string();
// Spawn PTY session // Spawn PTY session
let pty_session = PtySession::spawn_kubectl_attach( let pty_session = PtySession::spawn_kubectl_attach(
&kubectl_path, &params.kubectl_path,
&namespace, &params.namespace,
&pod, &params.pod,
container.as_deref(), params.container.as_deref(),
kubeconfig_path.as_deref(), params.kubeconfig_path.as_deref(),
) )
.context("Failed to spawn kubectl attach session")?; .context("Failed to spawn kubectl attach session")?;
self.register_session( self.register_session(
app_handle, app_handle,
session_id.clone(), session_id.clone(),
cluster_id, params,
namespace,
pod,
container,
SessionType::Attach, SessionType::Attach,
pty_session, pty_session,
) )
@ -141,10 +141,7 @@ impl SessionManager {
&self, &self,
app_handle: AppHandle, app_handle: AppHandle,
session_id: String, session_id: String,
cluster_id: String, params: SessionParams,
namespace: String,
pod: String,
container: Option<String>,
session_type: SessionType, session_type: SessionType,
pty_session: PtySession, pty_session: PtySession,
) -> Result<()> { ) -> Result<()> {
@ -153,10 +150,10 @@ impl SessionManager {
let info = SessionInfo { let info = SessionInfo {
id: session_id.clone(), id: session_id.clone(),
cluster_id, cluster_id: params.cluster_id,
namespace, namespace: params.namespace,
pod, pod: params.pod,
container, container: params.container,
session_type, session_type,
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
stdin_tx, stdin_tx,

View File

@ -43,7 +43,7 @@ export function InteractiveAttachModal({
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerminal | null>(null); const xtermRef = useRef<XTerminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null); const sessionIdRef = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const unlistenOutputRef = useRef<UnlistenFn | null>(null); const unlistenOutputRef = useRef<UnlistenFn | null>(null);
const unlistenClosedRef = useRef<UnlistenFn | null>(null); const unlistenClosedRef = useRef<UnlistenFn | null>(null);
@ -81,7 +81,7 @@ export function InteractiveAttachModal({
pod, pod,
container container
); );
setSessionId(sid); sessionIdRef.current = sid;
// Listen for output from backend // Listen for output from backend
const unlistenOutput = await listen<number[]>( const unlistenOutput = await listen<number[]>(
@ -144,8 +144,8 @@ export function InteractiveAttachModal({
if (unlistenErrorRef.current) { if (unlistenErrorRef.current) {
unlistenErrorRef.current(); unlistenErrorRef.current();
} }
if (sessionId) { if (sessionIdRef.current) {
terminatePtySessionCmd(sessionId).catch(console.error); terminatePtySessionCmd(sessionIdRef.current).catch(console.error);
} }
term.dispose(); term.dispose();
fitAddon.dispose(); fitAddon.dispose();
@ -169,8 +169,8 @@ export function InteractiveAttachModal({
}, []); }, []);
const handleClose = () => { const handleClose = () => {
if (sessionId) { if (sessionIdRef.current) {
terminatePtySessionCmd(sessionId).catch(console.error); terminatePtySessionCmd(sessionIdRef.current).catch(console.error);
} }
onClose(); onClose();
}; };

View File

@ -43,7 +43,7 @@ export function InteractiveShellModal({
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerminal | null>(null); const xtermRef = useRef<XTerminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null); const sessionIdRef = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const unlistenOutputRef = useRef<UnlistenFn | null>(null); const unlistenOutputRef = useRef<UnlistenFn | null>(null);
const unlistenClosedRef = useRef<UnlistenFn | null>(null); const unlistenClosedRef = useRef<UnlistenFn | null>(null);
@ -81,7 +81,7 @@ export function InteractiveShellModal({
pod, pod,
container container
); );
setSessionId(sid); sessionIdRef.current = sid;
// Listen for output from backend // Listen for output from backend
const unlistenOutput = await listen<number[]>( const unlistenOutput = await listen<number[]>(
@ -144,8 +144,8 @@ export function InteractiveShellModal({
if (unlistenErrorRef.current) { if (unlistenErrorRef.current) {
unlistenErrorRef.current(); unlistenErrorRef.current();
} }
if (sessionId) { if (sessionIdRef.current) {
terminatePtySessionCmd(sessionId).catch(console.error); terminatePtySessionCmd(sessionIdRef.current).catch(console.error);
} }
term.dispose(); term.dispose();
fitAddon.dispose(); fitAddon.dispose();
@ -169,8 +169,8 @@ export function InteractiveShellModal({
}, []); }, []);
const handleClose = () => { const handleClose = () => {
if (sessionId) { if (sessionIdRef.current) {
terminatePtySessionCmd(sessionId).catch(console.error); terminatePtySessionCmd(sessionIdRef.current).catch(console.error);
} }
onClose(); onClose();
}; };