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.
|
||||
/// 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 {
|
||||
// Remove leading dot if present
|
||||
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}"))?;
|
||||
|
||||
// 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
|
||||
.pty_sessions
|
||||
.start_exec_session(
|
||||
app,
|
||||
cluster_id,
|
||||
namespace,
|
||||
pod,
|
||||
container,
|
||||
kubectl_path.to_string_lossy().to_string(),
|
||||
kubeconfig_path,
|
||||
)
|
||||
.start_exec_session(app, params)
|
||||
.await
|
||||
.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}"))?;
|
||||
|
||||
// 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
|
||||
.pty_sessions
|
||||
.start_attach_session(
|
||||
app,
|
||||
cluster_id,
|
||||
namespace,
|
||||
pod,
|
||||
container,
|
||||
kubectl_path.to_string_lossy().to_string(),
|
||||
kubeconfig_path,
|
||||
)
|
||||
.start_attach_session(app, params)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start attach session: {e}"))?;
|
||||
|
||||
|
||||
@ -48,11 +48,27 @@ pub enum ControlCommand {
|
||||
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
|
||||
pub struct SessionManager {
|
||||
sessions: Arc<RwLock<HashMap<String, SessionInfo>>>,
|
||||
}
|
||||
|
||||
impl Default for SessionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@ -64,32 +80,24 @@ impl SessionManager {
|
||||
pub async fn start_exec_session(
|
||||
&self,
|
||||
app_handle: AppHandle,
|
||||
cluster_id: String,
|
||||
namespace: String,
|
||||
pod: String,
|
||||
container: Option<String>,
|
||||
kubectl_path: String,
|
||||
kubeconfig_path: Option<String>,
|
||||
params: SessionParams,
|
||||
) -> Result<String> {
|
||||
let session_id = Uuid::now_v7().to_string();
|
||||
|
||||
// Spawn PTY session
|
||||
let pty_session = PtySession::spawn_kubectl_exec(
|
||||
&kubectl_path,
|
||||
&namespace,
|
||||
&pod,
|
||||
container.as_deref(),
|
||||
kubeconfig_path.as_deref(),
|
||||
¶ms.kubectl_path,
|
||||
¶ms.namespace,
|
||||
¶ms.pod,
|
||||
params.container.as_deref(),
|
||||
params.kubeconfig_path.as_deref(),
|
||||
)
|
||||
.context("Failed to spawn kubectl exec session")?;
|
||||
|
||||
self.register_session(
|
||||
app_handle,
|
||||
session_id.clone(),
|
||||
cluster_id,
|
||||
namespace,
|
||||
pod,
|
||||
container,
|
||||
params,
|
||||
SessionType::Exec,
|
||||
pty_session,
|
||||
)
|
||||
@ -102,32 +110,24 @@ impl SessionManager {
|
||||
pub async fn start_attach_session(
|
||||
&self,
|
||||
app_handle: AppHandle,
|
||||
cluster_id: String,
|
||||
namespace: String,
|
||||
pod: String,
|
||||
container: Option<String>,
|
||||
kubectl_path: String,
|
||||
kubeconfig_path: Option<String>,
|
||||
params: SessionParams,
|
||||
) -> Result<String> {
|
||||
let session_id = Uuid::now_v7().to_string();
|
||||
|
||||
// Spawn PTY session
|
||||
let pty_session = PtySession::spawn_kubectl_attach(
|
||||
&kubectl_path,
|
||||
&namespace,
|
||||
&pod,
|
||||
container.as_deref(),
|
||||
kubeconfig_path.as_deref(),
|
||||
¶ms.kubectl_path,
|
||||
¶ms.namespace,
|
||||
¶ms.pod,
|
||||
params.container.as_deref(),
|
||||
params.kubeconfig_path.as_deref(),
|
||||
)
|
||||
.context("Failed to spawn kubectl attach session")?;
|
||||
|
||||
self.register_session(
|
||||
app_handle,
|
||||
session_id.clone(),
|
||||
cluster_id,
|
||||
namespace,
|
||||
pod,
|
||||
container,
|
||||
params,
|
||||
SessionType::Attach,
|
||||
pty_session,
|
||||
)
|
||||
@ -141,10 +141,7 @@ impl SessionManager {
|
||||
&self,
|
||||
app_handle: AppHandle,
|
||||
session_id: String,
|
||||
cluster_id: String,
|
||||
namespace: String,
|
||||
pod: String,
|
||||
container: Option<String>,
|
||||
params: SessionParams,
|
||||
session_type: SessionType,
|
||||
pty_session: PtySession,
|
||||
) -> Result<()> {
|
||||
@ -153,10 +150,10 @@ impl SessionManager {
|
||||
|
||||
let info = SessionInfo {
|
||||
id: session_id.clone(),
|
||||
cluster_id,
|
||||
namespace,
|
||||
pod,
|
||||
container,
|
||||
cluster_id: params.cluster_id,
|
||||
namespace: params.namespace,
|
||||
pod: params.pod,
|
||||
container: params.container,
|
||||
session_type,
|
||||
created_at: chrono::Utc::now(),
|
||||
stdin_tx,
|
||||
|
||||
@ -43,7 +43,7 @@ export function InteractiveAttachModal({
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<XTerminal | 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 unlistenOutputRef = useRef<UnlistenFn | null>(null);
|
||||
const unlistenClosedRef = useRef<UnlistenFn | null>(null);
|
||||
@ -81,7 +81,7 @@ export function InteractiveAttachModal({
|
||||
pod,
|
||||
container
|
||||
);
|
||||
setSessionId(sid);
|
||||
sessionIdRef.current = sid;
|
||||
|
||||
// Listen for output from backend
|
||||
const unlistenOutput = await listen<number[]>(
|
||||
@ -144,8 +144,8 @@ export function InteractiveAttachModal({
|
||||
if (unlistenErrorRef.current) {
|
||||
unlistenErrorRef.current();
|
||||
}
|
||||
if (sessionId) {
|
||||
terminatePtySessionCmd(sessionId).catch(console.error);
|
||||
if (sessionIdRef.current) {
|
||||
terminatePtySessionCmd(sessionIdRef.current).catch(console.error);
|
||||
}
|
||||
term.dispose();
|
||||
fitAddon.dispose();
|
||||
@ -169,8 +169,8 @@ export function InteractiveAttachModal({
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
if (sessionId) {
|
||||
terminatePtySessionCmd(sessionId).catch(console.error);
|
||||
if (sessionIdRef.current) {
|
||||
terminatePtySessionCmd(sessionIdRef.current).catch(console.error);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
@ -43,7 +43,7 @@ export function InteractiveShellModal({
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<XTerminal | 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 unlistenOutputRef = useRef<UnlistenFn | null>(null);
|
||||
const unlistenClosedRef = useRef<UnlistenFn | null>(null);
|
||||
@ -81,7 +81,7 @@ export function InteractiveShellModal({
|
||||
pod,
|
||||
container
|
||||
);
|
||||
setSessionId(sid);
|
||||
sessionIdRef.current = sid;
|
||||
|
||||
// Listen for output from backend
|
||||
const unlistenOutput = await listen<number[]>(
|
||||
@ -144,8 +144,8 @@ export function InteractiveShellModal({
|
||||
if (unlistenErrorRef.current) {
|
||||
unlistenErrorRef.current();
|
||||
}
|
||||
if (sessionId) {
|
||||
terminatePtySessionCmd(sessionId).catch(console.error);
|
||||
if (sessionIdRef.current) {
|
||||
terminatePtySessionCmd(sessionIdRef.current).catch(console.error);
|
||||
}
|
||||
term.dispose();
|
||||
fitAddon.dispose();
|
||||
@ -169,8 +169,8 @@ export function InteractiveShellModal({
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
if (sessionId) {
|
||||
terminatePtySessionCmd(sessionId).catch(console.error);
|
||||
if (sessionIdRef.current) {
|
||||
terminatePtySessionCmd(sessionIdRef.current).catch(console.error);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user