diff --git a/src-tauri/src/ssh/mod.rs b/src-tauri/src/ssh/mod.rs index b1c59be..adf144c 100644 --- a/src-tauri/src/ssh/mod.rs +++ b/src-tauri/src/ssh/mod.rs @@ -1,3 +1,4 @@ pub mod session; pub mod host_key; pub mod cwd; +pub mod monitor; diff --git a/src-tauri/src/ssh/monitor.rs b/src-tauri/src/ssh/monitor.rs new file mode 100644 index 0000000..336e216 --- /dev/null +++ b/src-tauri/src/ssh/monitor.rs @@ -0,0 +1,151 @@ +//! Remote system monitoring via SSH exec channels. +//! +//! Periodically runs lightweight system commands over a separate exec channel +//! (same pattern as CWD tracker) and emits stats to the frontend. +//! No agent installation required — uses standard POSIX and platform commands. + +use std::sync::Arc; + +use russh::client::Handle; +use russh::ChannelMsg; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use tokio::sync::Mutex as TokioMutex; + +use crate::ssh::session::SshClient; + +#[derive(Debug, Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SystemStats { + pub cpu_percent: f64, + pub mem_used_mb: u64, + pub mem_total_mb: u64, + pub mem_percent: f64, + pub disk_used_gb: f64, + pub disk_total_gb: f64, + pub disk_percent: f64, + pub net_rx_bytes: u64, + pub net_tx_bytes: u64, + pub os_type: String, +} + +/// Spawn a background task that polls system stats every 5 seconds. +pub fn start_monitor( + handle: Arc>>, + app_handle: AppHandle, + session_id: String, +) { + tokio::spawn(async move { + // Brief delay to let the shell start up + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + loop { + let stats = collect_stats(&handle).await; + + if let Some(stats) = stats { + let _ = app_handle.emit( + &format!("ssh:monitor:{}", session_id), + &stats, + ); + } + + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + }); +} + +async fn collect_stats(handle: &Arc>>) -> Option { + // Single command that works cross-platform: detect OS then gather stats + let script = r#" +OS=$(uname -s 2>/dev/null || echo "Unknown") +if [ "$OS" = "Linux" ]; then + CPU=$(grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$4+$5)} END {printf "%.1f", usage}') + MEM=$(free -m 2>/dev/null | awk '/^Mem:/ {printf "%d %d", $3, $2}') + DISK=$(df -BG / 2>/dev/null | awk 'NR==2 {gsub("G",""); printf "%s %s", $3, $2}') + NET=$(cat /proc/net/dev 2>/dev/null | awk '/eth0:|ens|enp|wlan0:/ {gsub(":",""); printf "%s %s", $2, $10; exit}') + echo "WRAITH_STATS:$OS:$CPU:$MEM:$DISK:$NET" +elif [ "$OS" = "Darwin" ]; then + CPU=$(ps -A -o %cpu | awk '{s+=$1} END {printf "%.1f", s/4}') + MEM_PAGES=$(vm_stat 2>/dev/null | awk '/Pages active/ {gsub(/\./,""); print $3}') + MEM_TOTAL=$(sysctl -n hw.memsize 2>/dev/null | awk '{printf "%d", $1/1048576}') + MEM_USED=$(echo "$MEM_PAGES" | awk -v t="$MEM_TOTAL" '{printf "%d", $1*4096/1048576}') + DISK=$(df -g / 2>/dev/null | awk 'NR==2 {printf "%s %s", $3, $2}') + NET=$(netstat -ib 2>/dev/null | awk '/en0/ && /Link/ {printf "%s %s", $7, $10; exit}') + echo "WRAITH_STATS:$OS:$CPU:$MEM_USED $MEM_TOTAL:$DISK:$NET" +else + echo "WRAITH_STATS:$OS:0:0 0:0 0:0 0" +fi +"#; + + let output = exec_command(handle, script).await?; + + for line in output.lines() { + if let Some(rest) = line.strip_prefix("WRAITH_STATS:") { + return parse_stats(rest); + } + } + + None +} + +fn parse_stats(raw: &str) -> Option { + let parts: Vec<&str> = raw.split(':').collect(); + if parts.len() < 5 { + return None; + } + + let os_type = parts[0].to_string(); + let cpu_percent = parts[1].parse::().unwrap_or(0.0); + + let mem_parts: Vec<&str> = parts[2].split_whitespace().collect(); + let mem_used = mem_parts.first().and_then(|s| s.parse::().ok()).unwrap_or(0); + let mem_total = mem_parts.get(1).and_then(|s| s.parse::().ok()).unwrap_or(1); + let mem_percent = if mem_total > 0 { (mem_used as f64 / mem_total as f64) * 100.0 } else { 0.0 }; + + let disk_parts: Vec<&str> = parts[3].split_whitespace().collect(); + let disk_used = disk_parts.first().and_then(|s| s.parse::().ok()).unwrap_or(0.0); + let disk_total = disk_parts.get(1).and_then(|s| s.parse::().ok()).unwrap_or(1.0); + let disk_percent = if disk_total > 0.0 { (disk_used / disk_total) * 100.0 } else { 0.0 }; + + let net_parts: Vec<&str> = parts.get(4).unwrap_or(&"0 0").split_whitespace().collect(); + let net_rx = net_parts.first().and_then(|s| s.parse::().ok()).unwrap_or(0); + let net_tx = net_parts.get(1).and_then(|s| s.parse::().ok()).unwrap_or(0); + + Some(SystemStats { + cpu_percent, + mem_used_mb: mem_used, + mem_total_mb: mem_total, + mem_percent, + disk_used_gb: disk_used, + disk_total_gb: disk_total, + disk_percent, + net_rx_bytes: net_rx, + net_tx_bytes: net_tx, + os_type, + }) +} + +async fn exec_command(handle: &Arc>>, cmd: &str) -> Option { + let mut channel = { + let h = handle.lock().await; + h.channel_open_session().await.ok()? + }; + + channel.exec(true, cmd).await.ok()?; + + let mut output = String::new(); + loop { + match channel.wait().await { + Some(ChannelMsg::Data { ref data }) => { + if let Ok(text) = std::str::from_utf8(data.as_ref()) { + output.push_str(text); + } + } + Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break, + Some(ChannelMsg::ExitStatus { .. }) => {} + _ => {} + } + } + + Some(output) +} diff --git a/src-tauri/src/ssh/session.rs b/src-tauri/src/ssh/session.rs index 9db89b0..4852d29 100644 --- a/src-tauri/src/ssh/session.rs +++ b/src-tauri/src/ssh/session.rs @@ -154,6 +154,9 @@ impl SshService { let scrollback_buf = scrollback.create(&session_id); error_watcher.watch(&session_id); + // Start remote monitoring if enabled (runs on a separate exec channel) + crate::ssh::monitor::start_monitor(handle.clone(), app_handle.clone(), session_id.clone()); + // Output reader loop — owns the Channel exclusively. // Writes go through Handle::data() so no shared mutex is needed. let sid = session_id.clone(); @@ -165,6 +168,10 @@ impl SshService { match msg { Some(ChannelMsg::Data { ref data }) => { scrollback_buf.push(data.as_ref()); + // Passive OSC 7 CWD detection — scan without modifying stream + if let Some(cwd) = extract_osc7_cwd(data.as_ref()) { + let _ = app.emit(&format!("ssh:cwd:{}", sid), &cwd); + } let encoded = base64::engine::general_purpose::STANDARD.encode(data.as_ref()); let _ = app.emit(&format!("ssh:data:{}", sid), encoded); } @@ -338,6 +345,56 @@ fn extract_dek_iv(pem_text: &str) -> Result<[u8; 16], String> { Err("No DEK-Info: AES-128-CBC header found in encrypted PEM".to_string()) } +/// Passively extract CWD from OSC 7 escape sequences in terminal output. +/// Format: \e]7;file://hostname/path\a or \e]7;file://hostname/path\e\\ +/// Returns the path portion without modifying the data stream. +fn extract_osc7_cwd(data: &[u8]) -> Option { + let text = std::str::from_utf8(data).ok()?; + // Look for OSC 7 pattern: \x1b]7;file:// + let marker = "\x1b]7;file://"; + let start = text.find(marker)?; + let after_marker = &text[start + marker.len()..]; + + // Skip hostname (everything up to the next /) + let path_start = after_marker.find('/')?; + let path_part = &after_marker[path_start..]; + + // Find the terminator: BEL (\x07) or ST (\x1b\\) + let end = path_part.find('\x07') + .or_else(|| path_part.find("\x1b\\").map(|i| i)); + + let path = match end { + Some(e) => &path_part[..e], + None => path_part, // Might be split across chunks — take what we have + }; + + if path.is_empty() { + None + } else { + // URL-decode the path (spaces encoded as %20, etc.) + Some(percent_decode(path)) + } +} + +fn percent_decode(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars(); + while let Some(ch) = chars.next() { + if ch == '%' { + let hex: String = chars.by_ref().take(2).collect(); + if let Ok(byte) = u8::from_str_radix(&hex, 16) { + output.push(byte as char); + } else { + output.push('%'); + output.push_str(&hex); + } + } else { + output.push(ch); + } + } + output +} + /// Resolve a private key string — if it looks like PEM content, return as-is. /// If it looks like a file path, read the file. Strip BOM and normalize. fn resolve_private_key(input: &str) -> Result { diff --git a/src/components/terminal/MonitorBar.vue b/src/components/terminal/MonitorBar.vue new file mode 100644 index 0000000..8324234 --- /dev/null +++ b/src/components/terminal/MonitorBar.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/components/terminal/TerminalView.vue b/src/components/terminal/TerminalView.vue index 56e9130..dd40253 100644 --- a/src/components/terminal/TerminalView.vue +++ b/src/components/terminal/TerminalView.vue @@ -52,6 +52,9 @@ @click="handleFocus" @focus="handleFocus" /> + + + @@ -59,6 +62,7 @@ import { ref, nextTick, onMounted, watch } from "vue"; import { useTerminal } from "@/composables/useTerminal"; import { useSessionStore } from "@/stores/session.store"; +import MonitorBar from "@/components/terminal/MonitorBar.vue"; import "@/assets/css/terminal.css"; const props = defineProps<{ diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 0d2b244..515187b 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -131,6 +131,7 @@