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:
parent
2a8183daf2
commit
16fdde20b2
@ -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);
|
||||||
|
|||||||
@ -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}"))?;
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
¶ms.kubectl_path,
|
||||||
&namespace,
|
¶ms.namespace,
|
||||||
&pod,
|
¶ms.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,
|
¶ms.kubectl_path,
|
||||||
&namespace,
|
¶ms.namespace,
|
||||||
&pod,
|
¶ms.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,
|
||||||
|
|||||||
@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user