dashboard (#1034)
* dashboard * fix: re-apply download behavior on recording context (#1019) * fix: re-apply download behavior on recording context record start creates a new browser context via Target.createBrowserContext. Browser.setDownloadBehavior called at launch only applies to the default context, so downloads in the recording context are silently dropped. Fix: 1. Store download_path on BrowserManager (from LaunchOptions) 2. After creating the recording context, call Browser.setDownloadBehavior with the new browserContextId This ensures downloads work during recording. Fixes #1018 * fix: add download_path to third BrowserManager constructor (auto_connect_cdp) * fix: reap zombie Chrome process and fast-detect crash for auto-restart (#1023) When Chrome crashes (e.g. SIGTRAP from CHECK() assertion), the daemon now: 1. Reaps the zombie immediately via a SIGCHLD handler in the event loop that calls waitpid(-1, WNOHANG) 2. Detects the crash instantly on the next command via a non-blocking try_wait() check (has_process_exited), avoiding the 3-second CDP timeout that is_connection_alive() would incur 3. Auto-relaunches Chrome transparently for the caller Fixes #1017 Co-authored-by: ctate <366502+ctate@users.noreply.github.com> * fix: route keyboard type through text input (#1014) * fix: handle --clear flag in console command (#1015) The console and errors commands parsed --clear from CLI args but the action handlers silently ignored the flag. The handlers did not accept the cmd parameter so they had no way to read the clear field. Changes: - Add clear_console() method to EventTracker in network.rs - Update handle_console to accept cmd, read the clear field, and clear the buffer when --clear is passed (returns {cleared: true}) - Update call site in execute_command to pass cmd Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com> * chore: patch release - ### Bug Fixes - **Re-apply download behavior on r... (#1025) * Add runtime stream enable/disable/status commands (#951) * Add runtime stream management commands * Run rustfmt and satisfy clippy * Fix stream disable cleanup semantics * Format stream disable regression tests * fix: retain radio/checkbox elements in compact snapshot tree (#1008) compact_tree() checked for "[ref=" to identify lines worth keeping, but radio and checkbox elements render as e.g. [checked=false, ref=e1] where the "[" opens before "checked=", not "ref=". Dropping the leading bracket so the check is just "ref=" fixes the match for all elements with refs. Fixes #1006 Co-authored-by: ctate <366502+ctate@users.noreply.github.com> * chore: version packages (#1027) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fixes * dashboard * fixes * remove observe * fmt * fixes * fixes * jotai * fmt * upload dashboard --------- Co-authored-by: Stefan Smiljkovic <stefan@vanila.io> Co-authored-by: ctate <366502+ctate@users.noreply.github.com> Co-authored-by: zhanba <c5e1856@gmail.com> Co-authored-by: xuyongliang <478439790@qq.com> Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com> Co-authored-by: Thomas Kosiewski <thoma471@googlemail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
ctate
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Stefan Smiljkovic
zhanba
xuyongliang
xuyongliang
Thomas Kosiewski
parent
63f03b8e06
commit
f9174513c2
+326
@@ -198,6 +198,278 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_dashboard_pid_path() -> std::path::PathBuf {
|
||||
get_socket_dir().join("dashboard.pid")
|
||||
}
|
||||
|
||||
fn is_pid_alive(pid: u32) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::kill(pid as i32, 0) == 0 }
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
||||
if handle != 0 {
|
||||
CloseHandle(handle);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_dashboard_start(port: u16, json_mode: bool) {
|
||||
let pid_path = get_dashboard_pid_path();
|
||||
|
||||
// Check if already running
|
||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<u32>() {
|
||||
if is_pid_alive(pid) {
|
||||
if json_mode {
|
||||
print_json_value(json!({
|
||||
"success": true,
|
||||
"data": { "port": port, "pid": pid, "already_running": true },
|
||||
}));
|
||||
} else {
|
||||
println!("Dashboard already running at http://localhost:{}", port);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
}
|
||||
|
||||
let socket_dir = get_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
}
|
||||
|
||||
let exe_path = match env::current_exe() {
|
||||
Ok(p) => p.canonicalize().unwrap_or(p),
|
||||
Err(e) => {
|
||||
if json_mode {
|
||||
print_json_error(format!("Failed to get executable path: {}", e));
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} Failed to get executable path: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let mut cmd = std::process::Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DASHBOARD", "1")
|
||||
.env("AGENT_BROWSER_DASHBOARD_PORT", port.to_string());
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
|
||||
}
|
||||
|
||||
match cmd
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => {
|
||||
let pid = child.id();
|
||||
let _ = fs::write(&pid_path, pid.to_string());
|
||||
|
||||
if json_mode {
|
||||
print_json_value(json!({
|
||||
"success": true,
|
||||
"data": { "port": port, "pid": pid },
|
||||
}));
|
||||
} else {
|
||||
println!("Dashboard started at http://localhost:{}", port);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if json_mode {
|
||||
print_json_error(format!("Failed to start dashboard: {}", e));
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} Failed to start dashboard: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_dashboard_stop(json_mode: bool) {
|
||||
let pid_path = get_dashboard_pid_path();
|
||||
|
||||
let pid_str = match fs::read_to_string(&pid_path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
if json_mode {
|
||||
print_json_value(
|
||||
json!({ "success": true, "data": { "stopped": false, "reason": "not running" } }),
|
||||
);
|
||||
} else {
|
||||
println!("Dashboard is not running");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let pid: u32 = match pid_str.trim().parse() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
if json_mode {
|
||||
print_json_value(
|
||||
json!({ "success": true, "data": { "stopped": false, "reason": "invalid pid" } }),
|
||||
);
|
||||
} else {
|
||||
println!("Dashboard is not running");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
unsafe {
|
||||
let handle = OpenProcess(1, 0, pid); // PROCESS_TERMINATE = 1
|
||||
if handle != 0 {
|
||||
windows_sys::Win32::System::Threading::TerminateProcess(handle, 0);
|
||||
CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
|
||||
if json_mode {
|
||||
print_json_value(json!({ "success": true, "data": { "stopped": true } }));
|
||||
} else {
|
||||
println!("{} Dashboard stopped", color::green("✓"));
|
||||
}
|
||||
}
|
||||
|
||||
fn run_close_all(flags: &Flags) {
|
||||
let socket_dir = get_socket_dir();
|
||||
let mut sessions: Vec<String> = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&socket_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if let Some(session_name) = name.strip_suffix(".pid") {
|
||||
if session_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pid_path = socket_dir.join(&name);
|
||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<u32>() {
|
||||
#[cfg(unix)]
|
||||
let running = unsafe {
|
||||
libc::kill(pid as i32, 0) == 0
|
||||
|| std::io::Error::last_os_error().raw_os_error()
|
||||
!= Some(libc::ESRCH)
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let running = unsafe {
|
||||
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
||||
if handle != 0 {
|
||||
CloseHandle(handle);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if running {
|
||||
sessions.push(session_name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sessions.is_empty() {
|
||||
if flags.json {
|
||||
print_json_value(json!({
|
||||
"success": true,
|
||||
"data": { "closed": 0, "sessions": [] },
|
||||
}));
|
||||
} else {
|
||||
println!("No active sessions");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut closed: Vec<String> = Vec::new();
|
||||
let mut failed: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for session in &sessions {
|
||||
let cmd = json!({ "id": gen_id(), "action": "close" });
|
||||
match send_command(cmd, session) {
|
||||
Ok(resp) if resp.success => closed.push(session.clone()),
|
||||
Ok(resp) => {
|
||||
let err = resp.error.unwrap_or_else(|| "Unknown error".to_string());
|
||||
failed.push((session.clone(), err));
|
||||
}
|
||||
Err(e) => failed.push((session.clone(), e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
if flags.json {
|
||||
print_json_value(json!({
|
||||
"success": failed.is_empty(),
|
||||
"data": {
|
||||
"closed": closed.len(),
|
||||
"sessions": closed,
|
||||
"failed": failed.iter().map(|(s, e)| json!({"session": s, "error": e})).collect::<Vec<_>>(),
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
for s in &closed {
|
||||
println!("{} Closed session: {}", color::green("✓"), s);
|
||||
}
|
||||
for (s, e) in &failed {
|
||||
eprintln!("{} Failed to close {}: {}", color::error_indicator(), s, e);
|
||||
}
|
||||
if closed.is_empty() && !failed.is_empty() {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if !failed.is_empty() {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Rust ignores SIGPIPE by default, causing println! to panic on broken pipes.
|
||||
// Reset to SIG_DFL so the OS terminates the process cleanly instead.
|
||||
@@ -227,6 +499,17 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Standalone dashboard server mode
|
||||
if env::var("AGENT_BROWSER_DASHBOARD").is_ok() {
|
||||
let port: u16 = env::var("AGENT_BROWSER_DASHBOARD_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(4848);
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
rt.block_on(native::stream::run_dashboard_server(port));
|
||||
return;
|
||||
}
|
||||
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
let flags = parse_flags(&args);
|
||||
let clean = clean_args(&args);
|
||||
@@ -267,12 +550,54 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle dashboard subcommand
|
||||
if clean.first().map(|s| s.as_str()) == Some("dashboard") {
|
||||
match clean.get(1).map(|s| s.as_str()) {
|
||||
Some("install") => {
|
||||
install::run_dashboard_install();
|
||||
return;
|
||||
}
|
||||
Some("start") | None => {
|
||||
let port = clean
|
||||
.iter()
|
||||
.position(|a| a == "--port")
|
||||
.and_then(|i| clean.get(i + 1))
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(4848);
|
||||
run_dashboard_start(port, flags.json);
|
||||
return;
|
||||
}
|
||||
Some("stop") => {
|
||||
run_dashboard_stop(flags.json);
|
||||
return;
|
||||
}
|
||||
Some(unknown) => {
|
||||
eprintln!(
|
||||
"{} Unknown dashboard subcommand: {}",
|
||||
color::error_indicator(),
|
||||
unknown
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle session separately (doesn't need daemon)
|
||||
if clean.first().map(|s| s.as_str()) == Some("session") {
|
||||
run_session(&clean, &flags.session, flags.json);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle close --all: close all active sessions
|
||||
if matches!(
|
||||
clean.first().map(|s| s.as_str()),
|
||||
Some("close") | Some("quit") | Some("exit")
|
||||
) && clean.iter().any(|a| a == "--all")
|
||||
{
|
||||
run_close_all(&flags);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cmd = match parse_command(&clean, &flags) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -397,6 +722,7 @@ fn main() {
|
||||
idle_timeout: flags.idle_timeout.as_deref(),
|
||||
cdp: flags.cdp.as_deref(),
|
||||
};
|
||||
|
||||
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user