feat: remote monitoring bar + SFTP tab follow + CWD macOS fix
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 15s

Remote monitoring bar:
- Slim 24px bar at bottom of every SSH terminal
- CPU, RAM, disk, network stats polled every 5s via exec channel
- Cross-platform: Linux (/proc), macOS (vm_stat/sysctl), FreeBSD
- Color-coded thresholds: green/amber/red
- No agent installation — standard POSIX commands only

SFTP follows active tab:
- Added :key="activeSessionId" to FileTree component
- Vue recreates FileTree when session changes, reinitializing SFTP

CWD tracking fix (macOS + all platforms):
- Old approach: exec channel pwd — returns HOME, not actual CWD
- New approach: passive OSC 7 parsing in the output stream
- Scans for \e]7;file://host/path\a without modifying data
- Works with bash, zsh, fish on both Linux and macOS
- Zero corruption risk — data passes through unmodified
- Includes URL percent-decoding for paths with spaces

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-24 23:38:01 -04:00
parent 216cd0cf34
commit 2ad6da43eb
6 changed files with 303 additions and 0 deletions

View File

@ -1,3 +1,4 @@
pub mod session;
pub mod host_key;
pub mod cwd;
pub mod monitor;

View File

@ -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<TokioMutex<Handle<SshClient>>>,
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<TokioMutex<Handle<SshClient>>>) -> Option<SystemStats> {
// 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<SystemStats> {
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::<f64>().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::<u64>().ok()).unwrap_or(0);
let mem_total = mem_parts.get(1).and_then(|s| s.parse::<u64>().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::<f64>().ok()).unwrap_or(0.0);
let disk_total = disk_parts.get(1).and_then(|s| s.parse::<f64>().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::<u64>().ok()).unwrap_or(0);
let net_tx = net_parts.get(1).and_then(|s| s.parse::<u64>().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<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
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)
}

View File

@ -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<String> {
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<String, String> {

View File

@ -0,0 +1,89 @@
<template>
<div
v-if="stats"
class="flex items-center gap-4 px-3 h-6 bg-[var(--wraith-bg-tertiary)] border-t border-[var(--wraith-border)] text-[10px] font-mono shrink-0 select-none"
>
<!-- CPU -->
<span class="flex items-center gap-1">
<span class="text-[var(--wraith-text-muted)]">CPU</span>
<span :class="colorClass(stats.cpuPercent, 50, 80)">{{ stats.cpuPercent.toFixed(0) }}%</span>
</span>
<!-- RAM -->
<span class="flex items-center gap-1">
<span class="text-[var(--wraith-text-muted)]">RAM</span>
<span :class="colorClass(stats.memPercent, 50, 80)">{{ stats.memUsedMb }}M/{{ stats.memTotalMb }}M ({{ stats.memPercent.toFixed(0) }}%)</span>
</span>
<!-- Disk -->
<span class="flex items-center gap-1">
<span class="text-[var(--wraith-text-muted)]">DISK</span>
<span :class="colorClass(stats.diskPercent, 70, 90)">{{ stats.diskUsedGb.toFixed(0) }}G/{{ stats.diskTotalGb.toFixed(0) }}G ({{ stats.diskPercent.toFixed(0) }}%)</span>
</span>
<!-- Network -->
<span class="flex items-center gap-1">
<span class="text-[var(--wraith-text-muted)]">NET</span>
<span class="text-[var(--wraith-text-secondary)]">{{ formatBytes(stats.netRxBytes) }} {{ formatBytes(stats.netTxBytes) }}</span>
</span>
<!-- OS -->
<span class="text-[var(--wraith-text-muted)] ml-auto">{{ stats.osType }}</span>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
const props = defineProps<{
sessionId: string;
}>();
interface SystemStats {
cpuPercent: number;
memUsedMb: number;
memTotalMb: number;
memPercent: number;
diskUsedGb: number;
diskTotalGb: number;
diskPercent: number;
netRxBytes: number;
netTxBytes: number;
osType: string;
}
const stats = ref<SystemStats | null>(null);
let unlistenFn: UnlistenFn | null = null;
function colorClass(value: number, warnThreshold: number, critThreshold: number): string {
if (value >= critThreshold) return "text-[#f85149]"; // red
if (value >= warnThreshold) return "text-[#e3b341]"; // amber
return "text-[#3fb950]"; // green
}
function formatBytes(bytes: number): string {
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + "G";
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + "M";
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + "K";
return bytes + "B";
}
async function subscribe(): Promise<void> {
if (unlistenFn) unlistenFn();
unlistenFn = await listen<SystemStats>(`ssh:monitor:${props.sessionId}`, (event) => {
stats.value = event.payload;
});
}
onMounted(subscribe);
watch(() => props.sessionId, () => {
stats.value = null;
subscribe();
});
onBeforeUnmount(() => {
if (unlistenFn) unlistenFn();
});
</script>

View File

@ -52,6 +52,9 @@
@click="handleFocus"
@focus="handleFocus"
/>
<!-- Remote monitoring bar -->
<MonitorBar :session-id="props.sessionId" />
</div>
</template>
@ -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<{

View File

@ -131,6 +131,7 @@
<template v-else-if="sidebarTab === 'sftp'">
<template v-if="activeSessionId">
<FileTree
:key="activeSessionId"
:session-id="activeSessionId"
class="flex-1 min-h-0"
@open-file="handleOpenFile"