From 303ecd83686bdedb4b28e3bcaabd85c9933d9519 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 21 Nov 2025 12:20:11 -0500 Subject: [PATCH 01/25] Removed a lot of unwraps Removing the unwraps now has better error handling and will cause the program to crash with crash dumps instead of panic! --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 40 +++++++++++++++++++++++++++++------- requirements.txt | 2 +- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index fb2d657..7d41bb2 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "3.1.2" +version = "3.2.0" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 1fb0612..7ae3aaa 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "3.1.2" +version = "3.2.0" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index b646277..32a8a0d 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "3.1.2" +version = "3.2.0" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index c38adeb..7d1c1a5 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -25,6 +25,7 @@ use std::{ str::FromStr, }; +#[allow(non_snake_case)] #[derive(Deserialize, Debug)] struct TelemetryConfig { TELEMETRY: bool, @@ -86,13 +87,25 @@ pub fn pull_policy_exec_histories( ) .into(); let writeable_filepath = file_path.clone(); - if !file_path.exists() { - if let Some(parent_dir) = file_path.parent() + if !&file_path.exists() { + if let Some(parent_dir) = &file_path.parent() && !parent_dir.exists() { - fs::create_dir_all(parent_dir).unwrap(); + match fs::create_dir_all(parent_dir) { + Ok(_) => {}, + Err(e) => { + println!("Failed to Create Directory {:?}: {}", parent_dir, e); + std::process::abort(); + } + } + } + match fs::File::create(&file_path) { + Ok(_) => {}, + Err(e) => { + println!("Failed to Create Directory {:?}: {}", &file_path, e); + std::process::abort(); + } } - fs::File::create(file_path).unwrap(); } let data = ApiResponse { error: "Success".to_string(), @@ -135,18 +148,31 @@ pub fn pull_policy_exec_histories( ); cx.span() .set_status(Status::error("Client Failed to Build")); - panic!("Failed to Build Client: {:?}", client_result); + println!("Failed to Build Client: {:?}", client_result); + std::process::abort(); } } }); let api: Py = py_self; let cutoff = Local::now().naive_local() - Duration::days(days); - let mut f = File::open(&writeable_filepath).unwrap(); + let mut f = match File::open(&writeable_filepath) { + Ok(f) => f, + Err(e) => { + println!("Failed to Access {:?}: {}", &writeable_filepath, e); + std::process::abort(); + } + }; tracer.in_span("Airlock Data Retreival", |cx| { let span = cx.span(); span.set_attribute(Key::new("Days").string(days.to_string().to_string())); loop { - f.seek(SeekFrom::Start(0)).unwrap(); + match f.seek(SeekFrom::Start(0)) { + Ok(_) => {}, + Err(e) => { + println!("Failed to seek start of {:?}: {}", f, e); + std::process::abort(); + } + } let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { let results: ApiResponse = history_logging( py, diff --git a/requirements.txt b/requirements.txt index a89fe9e..7110c1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==3.1.2 \ No newline at end of file +airlock_libs==3.2.0 \ No newline at end of file -- 2.34.1 From 87ab1e3b2857d0245da9017144657d2af55de7ab Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 21 Nov 2025 14:00:09 -0500 Subject: [PATCH 02/25] Telemetry Config Change Added implementation instead of separate function to load Telemetry configuration --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 51 ++++++++++++++++++------------------ requirements.txt | 2 +- 5 files changed, 29 insertions(+), 30 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 7d41bb2..6551e5e 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "3.2.0" +version = "4.0.0" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 7ae3aaa..967f0a2 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "3.2.0" +version = "4.0.0" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 32a8a0d..c56d78e 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "3.2.0" +version = "4.0.0" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index 7d1c1a5..6c6d68d 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -32,6 +32,27 @@ struct TelemetryConfig { TELEM_URL: Option, } +impl TelemetryConfig { + pub fn load() -> Self { + let cfg_path = get_base_directory().join("config\\user_config.json"); + if !cfg_path.exists() { + return Self { + TELEMETRY: false, + TELEM_URL: None, + }; + } + match fs::read_to_string(&cfg_path) { + Ok(contents) => serde_json::from_str::(&contents).unwrap_or(Self { + TELEMETRY: false, + TELEM_URL: None, + }), + Err(_) => Self { + TELEMETRY: false, + TELEM_URL: None, + }, + } + } +} #[derive(Debug, Deserialize, Serialize)] struct ApiResponse { error: String, @@ -92,7 +113,7 @@ pub fn pull_policy_exec_histories( && !parent_dir.exists() { match fs::create_dir_all(parent_dir) { - Ok(_) => {}, + Ok(_) => {} Err(e) => { println!("Failed to Create Directory {:?}: {}", parent_dir, e); std::process::abort(); @@ -100,7 +121,7 @@ pub fn pull_policy_exec_histories( } } match fs::File::create(&file_path) { - Ok(_) => {}, + Ok(_) => {} Err(e) => { println!("Failed to Create Directory {:?}: {}", &file_path, e); std::process::abort(); @@ -167,7 +188,7 @@ pub fn pull_policy_exec_histories( span.set_attribute(Key::new("Days").string(days.to_string().to_string())); loop { match f.seek(SeekFrom::Start(0)) { - Ok(_) => {}, + Ok(_) => {} Err(e) => { println!("Failed to seek start of {:?}: {}", f, e); std::process::abort(); @@ -360,30 +381,8 @@ fn skipback(days: i64) -> ObjectId { ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") } -fn load_telemetry_config() -> TelemetryConfig { - let cfg_path = get_base_directory().join("config\\user_config.json"); - if !cfg_path.exists() { - return TelemetryConfig { - TELEMETRY: false, - TELEM_URL: None, - }; - } - match fs::read_to_string(&cfg_path) { - Ok(contents) => { - serde_json::from_str::(&contents).unwrap_or(TelemetryConfig { - TELEMETRY: false, - TELEM_URL: None, - }) - } - Err(_) => TelemetryConfig { - TELEMETRY: false, - TELEM_URL: None, - }, - } -} - fn init_tracer() -> Result, TraceError> { - let cfg = load_telemetry_config(); + let cfg = TelemetryConfig::load(); if !cfg.TELEMETRY { global::set_tracer_provider(NoopTracerProvider::new()); return Ok(None); diff --git a/requirements.txt b/requirements.txt index 7110c1e..dda7a4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==3.2.0 \ No newline at end of file +airlock_libs==4.0.0 \ No newline at end of file -- 2.34.1 From dd206eb2724cbf36980b1be6e644c59623a838a3 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Mon, 24 Nov 2025 10:30:52 -0500 Subject: [PATCH 03/25] Feature closes #37 --- airlock_libs/.gitignore | 3 ++- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 1 + requirements.txt | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/airlock_libs/.gitignore b/airlock_libs/.gitignore index 84ed743..5420cda 100644 --- a/airlock_libs/.gitignore +++ b/airlock_libs/.gitignore @@ -1,3 +1,4 @@ /target build.sh -pythontest.py \ No newline at end of file +pythontest.py +changelog.md \ No newline at end of file diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 6551e5e..0f8f279 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "4.0.0" +version = "4.0.1" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 967f0a2..85be7cd 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "4.0.0" +version = "4.0.1" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index c56d78e..040ddb0 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "4.0.0" +version = "4.0.1" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index 6c6d68d..db1aa60 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -186,6 +186,7 @@ pub fn pull_policy_exec_histories( tracer.in_span("Airlock Data Retreival", |cx| { let span = cx.span(); span.set_attribute(Key::new("Days").string(days.to_string().to_string())); + span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); loop { match f.seek(SeekFrom::Start(0)) { Ok(_) => {} diff --git a/requirements.txt b/requirements.txt index dda7a4e..c88d6c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==4.0.0 \ No newline at end of file +airlock_libs==4.0.1 \ No newline at end of file -- 2.34.1 From a80c2ca1e12b8c3bba1208962c6c722bc1818159 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Mon, 24 Nov 2025 11:36:50 -0500 Subject: [PATCH 04/25] Features closes #34 Removed a lot of unwraps, most remaining unwraps will likely stay in the code, as it will be expected behavior to panic and crash rather than a system level abort/exit event --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 31 +++++++++++++++++++++++++++---- requirements.txt | 2 +- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 0f8f279..bc4c86f 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "4.0.1" +version = "4.0.2" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 85be7cd..957f0fe 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "4.0.1" +version = "4.0.2" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 040ddb0..f5cdea9 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "4.0.1" +version = "4.0.2" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index db1aa60..b437639 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -96,7 +96,13 @@ pub fn pull_policy_exec_histories( exec_types: String, days: i64, ) -> Py { - let rt = tokio::runtime::Runtime::new().unwrap(); + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + println!("Failed to build Tokio Runtime: {:?}", e); + std::process::abort(); + } + }; rt.block_on(async { let _ = init_tracer(); }); @@ -135,7 +141,13 @@ pub fn pull_policy_exec_histories( }, }; let data_write = serde_json::to_string_pretty(&data).expect("Failed to serialize"); - fs::write(writeable_filepath.clone(), data_write).unwrap(); + match fs::write(writeable_filepath.clone(), data_write) { + Ok(_) => {} + Err(e) => { + println!("Failed to write to: {:?}: {}", &writeable_filepath, e); + std::process::abort(); + } + } let mut checkpoint_number: String = skipback(days).to_string(); let multi_progress = MultiProgress::new(); multi_progress.set_draw_target(ProgressDrawTarget::stdout()); @@ -274,7 +286,12 @@ pub fn pull_policy_exec_histories( }, }; let data_write = serde_json::to_string_pretty(&final_response).unwrap(); - fs::write(&writeable_filepath, data_write).unwrap(); + match fs::write(&writeable_filepath, data_write) { + Ok(_) => {} + Err(e) => { + println!("Failed to write to: {:?}: {}", &writeable_filepath, e); + } + } if let Some(last_item) = &final_response.response.exechistories.last() && let Ok(last_date) = NaiveDate::parse_from_str( &last_item.datetime.replace(" +0000 UTC", ""), @@ -289,7 +306,13 @@ pub fn pull_policy_exec_histories( } }); progress_bar.finish_with_message("All Checkpoints Complete"); - let return_data = fs::read_to_string(&writeable_filepath).unwrap(); + let return_data = match fs::read_to_string(&writeable_filepath) { + Ok(return_data) => return_data, + Err(e) => { + println!("Failed to read data from: {:?}: {}", &writeable_filepath, e); + std::process::abort(); + } + }; shutdown_tracer_provider(); PyString::new(py, &return_data).into() } diff --git a/requirements.txt b/requirements.txt index c88d6c0..e3c292a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==4.0.1 \ No newline at end of file +airlock_libs==4.0.2 \ No newline at end of file -- 2.34.1 From b289e9324ccee067d1f82b8e1aa51a5bdf6ca38b Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Tue, 25 Nov 2025 15:27:16 -0500 Subject: [PATCH 05/25] Slight Changes Refactor of Loxide Libs Removed spaces, imports, and converters from code --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 2 +- requirements.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index bc4c86f..49c79c6 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "4.0.2" +version = "4.0.3" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 957f0fe..c4aa9b1 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "4.0.2" +version = "4.0.3" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index f5cdea9..5edc9c0 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "4.0.2" +version = "4.0.3" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index b437639..c104db5 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -197,7 +197,7 @@ pub fn pull_policy_exec_histories( }; tracer.in_span("Airlock Data Retreival", |cx| { let span = cx.span(); - span.set_attribute(Key::new("Days").string(days.to_string().to_string())); + span.set_attribute(Key::new("Days").string(days.to_string())); span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); loop { match f.seek(SeekFrom::Start(0)) { diff --git a/requirements.txt b/requirements.txt index e3c292a..705d525 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==4.0.2 \ No newline at end of file +airlock_libs==4.0.3 \ No newline at end of file -- 2.34.1 From 6327adeabf03240b487e8665e24a543b23d54e9a Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Tue, 25 Nov 2025 15:27:16 -0500 Subject: [PATCH 06/25] Refactor of Loxide Libs Removed spaces, imports, and converters from code --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 2 +- requirements.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index bc4c86f..49c79c6 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "4.0.2" +version = "4.0.3" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 957f0fe..c4aa9b1 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "4.0.2" +version = "4.0.3" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index f5cdea9..5edc9c0 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "4.0.2" +version = "4.0.3" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index b437639..c104db5 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -197,7 +197,7 @@ pub fn pull_policy_exec_histories( }; tracer.in_span("Airlock Data Retreival", |cx| { let span = cx.span(); - span.set_attribute(Key::new("Days").string(days.to_string().to_string())); + span.set_attribute(Key::new("Days").string(days.to_string())); span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); loop { match f.seek(SeekFrom::Start(0)) { diff --git a/requirements.txt b/requirements.txt index e3c292a..705d525 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==4.0.2 \ No newline at end of file +airlock_libs==4.0.3 \ No newline at end of file -- 2.34.1 From f3c1d97d28edb48c07c990542a03d783c03d31bb Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Tue, 2 Dec 2025 10:53:25 -0500 Subject: [PATCH 07/25] Implemented Threaded Rust Process It still locks the GIL, but this is the ground work for the non blocking GUI. Python objects are converted to Rust objects in the beginning of the function call so they can be sent to threads safely. --- airlock_libs/Cargo.lock | 833 ++++++++++++++++++++++------------- airlock_libs/Cargo.toml | 3 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 457 ++++++++++--------- requirements.txt | 2 +- 5 files changed, 784 insertions(+), 513 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 49c79c6..52dd2a2 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "4.0.3" +version = "5.0.0" dependencies = [ "chrono", "indicatif", @@ -36,6 +36,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry-semantic-conventions", "pyo3", + "pyo3-async-runtimes", "reqwest", "serde", "serde-pyobject", @@ -62,6 +63,150 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix 1.1.2", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -81,9 +226,15 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -92,7 +243,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -203,6 +354,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bson" version = "2.15.0" @@ -215,7 +379,7 @@ dependencies = [ "getrandom 0.2.16", "getrandom 0.3.4", "hex", - "indexmap 2.12.0", + "indexmap 2.12.1", "js-sys", "once_cell", "rand 0.9.2", @@ -234,15 +398,15 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.45" +version = "1.2.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" dependencies = [ "find-msvc-tools", "shlex", @@ -263,9 +427,17 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", - "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", ] [[package]] @@ -301,12 +473,6 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "core-foundation" version = "0.9.4" @@ -332,6 +498,12 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -341,6 +513,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -384,7 +565,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -395,7 +576,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -424,7 +605,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -435,7 +615,7 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -446,20 +626,28 @@ checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] name = "derive_more" -version = "0.99.20" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ - "convert_case", "proc-macro2", "quote", - "rustc_version", - "syn 2.0.110", + "syn 2.0.111", + "unicode-xid", ] [[package]] @@ -481,15 +669,9 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "either" version = "1.15.0" @@ -520,7 +702,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -539,6 +721,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -547,9 +756,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fixedbitset" @@ -601,6 +810,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -640,6 +850,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -648,7 +871,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -718,6 +941,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.3.27" @@ -730,7 +965,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.12.0", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -748,8 +983,8 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.3.1", - "indexmap 2.12.0", + "http 1.4.0", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -770,9 +1005,9 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -786,6 +1021,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -794,9 +1035,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hickory-proto" -version = "0.24.4" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", "cfg-if", @@ -808,8 +1049,9 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.8.5", - "thiserror 1.0.69", + "rand 0.9.2", + "ring 0.17.14", + "thiserror 2.0.17", "tinyvec", "tokio", "tracing", @@ -818,21 +1060,21 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.24.4" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ "cfg-if", "futures-util", "hickory-proto", "ipconfig", - "lru-cache", + "moka", "once_cell", "parking_lot", - "rand 0.8.5", + "rand 0.9.2", "resolv-conf", "smallvec", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -868,12 +1110,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -895,7 +1136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http 1.4.0", ] [[package]] @@ -906,7 +1147,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "pin-project-lite", ] @@ -949,16 +1190,16 @@ dependencies = [ [[package]] name = "hyper" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2 0.4.12", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "httparse", "itoa", @@ -975,8 +1216,8 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.3.1", - "hyper 1.8.0", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", "rustls 0.23.35", "rustls-pki-types", @@ -1005,7 +1246,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.8.0", + "hyper 1.8.1", "hyper-util", "native-tls", "tokio", @@ -1015,18 +1256,18 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", - "hyper 1.8.0", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", @@ -1179,19 +1420,16 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", - "serde", ] [[package]] name = "indexmap" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.0", - "serde", - "serde_core", + "hashbrown 0.16.1", ] [[package]] @@ -1261,14 +1499,23 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1277,15 +1524,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "linux-raw-sys" @@ -1319,14 +1560,8 @@ name = "log" version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" - -[[package]] -name = "lru-cache" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" dependencies = [ - "linked-hash-map", + "value-bag", ] [[package]] @@ -1338,7 +1573,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1352,7 +1587,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1363,7 +1598,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1374,7 +1609,7 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1426,10 +1661,28 @@ dependencies = [ ] [[package]] -name = "mongocrypt" -version = "0.3.1" +name = "moka" +version = "0.12.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22426d6318d19c5c0773f783f85375265d6a8f0fa76a733da8dc4355516ec63d" +checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "mongocrypt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da0cd419a51a5fb44819e290fbdb0665a54f21dead8923446a799c7f4d26ad9" dependencies = [ "bson", "mongocrypt-sys", @@ -1439,25 +1692,22 @@ dependencies = [ [[package]] name = "mongocrypt-sys" -version = "0.1.4+1.12.0" +version = "0.1.5+1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dda42df21d035f88030aad8e877492fac814680e1d7336a57b2a091b989ae388" +checksum = "224484c5d09285a7b8cb0a0c117e847ebd14cb6e4470ecf68cdb89c503b0edb9" [[package]] name = "mongodb" -version = "3.3.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f272c59e54a3c85f5902c6b8e7b1653a6b6681f45e4c42d6581301119a4b8" +checksum = "12f5c20217413bed97c613714e6d6dfe39ef59dd79a68999f1043b0566192975" dependencies = [ - "async-trait", - "base64 0.13.1", - "bitflags 1.3.2", + "base64 0.22.1", + "bitflags 2.10.0", "bson", - "chrono", "derive-where", "derive_more", "futures-core", - "futures-executor", "futures-io", "futures-util", "hex", @@ -1468,10 +1718,9 @@ dependencies = [ "md-5", "mongocrypt", "mongodb-internal-macros", - "once_cell", "pbkdf2", "percent-encoding", - "rand 0.8.5", + "rand 0.9.2", "rustc_version_runtime", "rustls 0.23.35", "rustversion", @@ -1480,29 +1729,29 @@ dependencies = [ "serde_with", "sha1", "sha2", - "socket2 0.5.10", + "socket2 0.6.1", "stringprep", "strsim", "take_mut", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tokio-rustls 0.26.4", "tokio-util", "typed-builder", "uuid", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] name = "mongodb-internal-macros" -version = "3.3.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63981427a0f26b89632fd2574280e069d09fb2912a3138da15de0174d11dd077" +checksum = "20033442aa13664e70bc9f8be1bacabebf6a31b6d4bb5608ceb99c4ec96e9951" dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1557,6 +1806,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "openssl" @@ -1581,7 +1834,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1719,6 +1972,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1739,14 +1998,14 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "pbkdf2" -version = "0.11.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", ] @@ -1764,7 +2023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.12.0", + "indexmap 2.12.1", ] [[package]] @@ -1784,7 +2043,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1799,12 +2058,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -1910,9 +2194,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf" +checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d" dependencies = [ "indoc", "libc", @@ -1926,10 +2210,24 @@ dependencies = [ ] [[package]] -name = "pyo3-build-config" -version = "0.27.1" +name = "pyo3-async-runtimes" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb" +checksum = "57ddb5b570751e93cc6777e81fee8087e59cd53b5043292f2a6d59d5bd80fdfd" +dependencies = [ + "async-std", + "futures", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6" dependencies = [ "python3-dll-a", "target-lexicon", @@ -1937,9 +2235,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be" +checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089" dependencies = [ "libc", "pyo3-build-config", @@ -1947,27 +2245,27 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71" +checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] name = "pyo3-macros-backend" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b" +checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9" dependencies = [ "heck 0.5.0", "proc-macro2", "pyo3-build-config", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -2068,26 +2366,6 @@ dependencies = [ "bitflags 2.10.0", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.110", -] - [[package]] name = "regex" version = "1.12.2" @@ -2128,10 +2406,10 @@ dependencies = [ "encoding_rs", "futures-core", "h2 0.4.12", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.8.0", + "hyper 1.8.1", "hyper-rustls", "hyper-tls", "hyper-util", @@ -2159,9 +2437,9 @@ dependencies = [ [[package]] name = "resolv-conf" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "ring" @@ -2221,7 +2499,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2287,9 +2565,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ "zeroize", ] @@ -2326,30 +2604,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2443,7 +2697,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -2452,7 +2706,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.12.1", "itoa", "memchr", "ryu", @@ -2474,33 +2728,24 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.15.1" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.12.0", - "schemars 0.9.0", - "schemars 1.1.0", "serde_core", - "serde_json", "serde_with_macros", - "time", ] [[package]] name = "serde_with_macros" -version = "3.15.1" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -2542,9 +2787,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" dependencies = [ "libc", ] @@ -2629,9 +2874,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -2661,7 +2906,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -2685,6 +2930,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "take_mut" version = "0.2.2" @@ -2742,7 +2993,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -2753,7 +3004,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -2865,7 +3116,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3009,14 +3260,14 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "iri-string", "pin-project-lite", @@ -3039,9 +3290,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -3050,20 +3301,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -3111,9 +3362,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -3131,22 +3382,22 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typed-builder" -version = "0.20.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" dependencies = [ "typed-builder-macro", ] [[package]] name = "typed-builder-macro" -version = "0.20.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3188,6 +3439,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unindent" version = "0.2.4" @@ -3196,9 +3453,9 @@ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" [[package]] name = "unit-prefix" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] name = "untrusted" @@ -3232,13 +3489,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -3248,6 +3505,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" @@ -3286,9 +3549,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -3299,9 +3562,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -3312,9 +3575,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3322,31 +3585,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -3372,15 +3635,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.4", -] - [[package]] name = "webpki-roots" version = "1.0.4" @@ -3438,9 +3692,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -3451,7 +3705,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3462,15 +3716,9 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -3479,22 +3727,13 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -3503,16 +3742,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -3521,7 +3751,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3542,6 +3772,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -3557,7 +3796,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3597,7 +3836,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3796,28 +4035,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3837,7 +4076,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "synstructure", ] @@ -3877,5 +4116,5 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index c4aa9b1..07ad133 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "4.0.3" +version = "5.0.0" edition = "2024" [lib] @@ -24,6 +24,7 @@ tonic = { version = "0.8.2", features = ["tls-roots"] } tracing = "0.1.41" tracing-subscriber = "0.3.20" tracing-opentelemetry = "0.32.0" +pyo3-async-runtimes = { version = "0.27.0", features = ["async-std", "tokio"] } [package.metadata.maturin] generate-abi-stubs = true diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 5edc9c0..23e08a3 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "4.0.3" +version = "5.0.0" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index c104db5..14f191d 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -9,6 +9,7 @@ use opentelemetry::{Context, KeyValue, sdk::trace as sdktrace, trace::Tracer}; use opentelemetry::{Key, global}; use opentelemetry_otlp::WithExportConfig; use pyo3::{prelude::*, types::PyString}; +use pyo3_async_runtimes::async_std; use reqwest::{ Client, header::{HeaderMap, HeaderName, HeaderValue}, @@ -88,6 +89,40 @@ struct Group { localip: String, } +enum ExtractedValues { + Headers(reqwest::header::HeaderMap), + BaseUrl(String), +} + +trait Converter { + fn convert(py: Python<'_>, py_self: &Py, extract_headers: bool) -> ExtractedValues; +} + +struct PyData; + +impl Converter for PyData { + fn convert(py: Python<'_>, py_self: &Py, extract_headers: bool) -> ExtractedValues { + if extract_headers { + let headers = py_self.getattr(py, "headers").unwrap().to_string(); + let headers_replace = headers.replace('\'', "\""); + let parsed: Value = serde_json::from_str(headers_replace.as_str()).unwrap(); + let mut header_map = HeaderMap::new(); + if let Some(obj) = parsed.as_object() { + for (_key, value) in obj { + if let Some(v) = value.as_str() { + let val = HeaderValue::from_str(v).unwrap(); + header_map.insert(HeaderName::from_str("X-APIKey").unwrap(), val); + } + } + } + ExtractedValues::Headers(header_map) + } else { + let base_url = py_self.getattr(py, "base_url").unwrap().to_string(); + ExtractedValues::BaseUrl(base_url) + } + } +} + #[pyfunction] pub fn pull_policy_exec_histories( py: Python<'_>, @@ -96,258 +131,254 @@ pub fn pull_policy_exec_histories( exec_types: String, days: i64, ) -> Py { - let rt = match tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(e) => { - println!("Failed to build Tokio Runtime: {:?}", e); - std::process::abort(); - } + let headers: HeaderMap = match PyData::convert(py, &py_self, true) { + ExtractedValues::Headers(h) => h, + ExtractedValues::BaseUrl(_) => std::process::abort(), }; - rt.block_on(async { - let _ = init_tracer(); - }); - let tracer = global::tracer("global_tracer"); - let _cx = Context::new(); - let file_path: PathBuf = format!( - "{}\\cache\\chunkinator.json", - get_base_directory().display() - ) - .into(); - let writeable_filepath = file_path.clone(); - if !&file_path.exists() { - if let Some(parent_dir) = &file_path.parent() - && !parent_dir.exists() - { - match fs::create_dir_all(parent_dir) { + let base_url = match PyData::convert(py, &py_self, false) { + ExtractedValues::Headers(_) => std::process::abort(), + ExtractedValues::BaseUrl(b) => b, + }; + let handle = std::thread::spawn(move || { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + println!("Failed to build Tokio Runtime: {:?}", e); + std::process::abort(); + } + }; + rt.block_on(async { + let _ = init_tracer(); + }); + let tracer = global::tracer("global_tracer"); + let _cx = Context::new(); + let file_path: PathBuf = format!( + "{}\\cache\\chunkinator.json", + get_base_directory().display() + ) + .into(); + let writeable_filepath = file_path.clone(); + if !&file_path.exists() { + if let Some(parent_dir) = &file_path.parent() + && !parent_dir.exists() + { + match fs::create_dir_all(parent_dir) { + Ok(_) => {} + Err(e) => { + println!("Failed to Create Directory {:?}: {}", parent_dir, e); + std::process::abort(); + } + } + } + match fs::File::create(&file_path) { Ok(_) => {} Err(e) => { - println!("Failed to Create Directory {:?}: {}", parent_dir, e); + println!("Failed to Create Directory {:?}: {}", &file_path, e); std::process::abort(); } } } - match fs::File::create(&file_path) { + let data = ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: vec![], + }, + }; + let data_write = serde_json::to_string_pretty(&data).expect("Failed to serialize"); + match fs::write(writeable_filepath.clone(), data_write) { Ok(_) => {} Err(e) => { - println!("Failed to Create Directory {:?}: {}", &file_path, e); + println!("Failed to write to: {:?}: {}", &writeable_filepath, e); std::process::abort(); } } - } - let data = ApiResponse { - error: "Success".to_string(), - response: ExecHistories { - exechistories: vec![], - }, - }; - let data_write = serde_json::to_string_pretty(&data).expect("Failed to serialize"); - match fs::write(writeable_filepath.clone(), data_write) { - Ok(_) => {} - Err(e) => { - println!("Failed to write to: {:?}: {}", &writeable_filepath, e); - std::process::abort(); - } - } - let mut checkpoint_number: String = skipback(days).to_string(); - let multi_progress = MultiProgress::new(); - multi_progress.set_draw_target(ProgressDrawTarget::stdout()); - let progress_bar = multi_progress.add(ProgressBar::new(100)); - progress_bar.set_style( - ProgressStyle::default_bar() - .template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len}") - .unwrap(), - ); - progress_bar.enable_steady_tick(std::time::Duration::from_millis(100)); - let client = tracer.in_span("Building HTTP Client", |cx| { - let client_result = build_client(py, &py_self); - match client_result { - Ok(client_result) => { - cx.span().add_event( - "info", - vec![KeyValue::new( - "Client Built Successfully", - format!("{:?}", client_result), - )], - ); - client_result - } - Err(client_result) => { - cx.span().add_event( - "warn", - vec![KeyValue::new( - "Client Failed to Build", - format!("{:?}", &client_result), - )], - ); - cx.span() - .set_status(Status::error("Client Failed to Build")); - println!("Failed to Build Client: {:?}", client_result); - std::process::abort(); - } - } - }); - let api: Py = py_self; - let cutoff = Local::now().naive_local() - Duration::days(days); - let mut f = match File::open(&writeable_filepath) { - Ok(f) => f, - Err(e) => { - println!("Failed to Access {:?}: {}", &writeable_filepath, e); - std::process::abort(); - } - }; - tracer.in_span("Airlock Data Retreival", |cx| { - let span = cx.span(); - span.set_attribute(Key::new("Days").string(days.to_string())); - span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); - loop { - match f.seek(SeekFrom::Start(0)) { - Ok(_) => {} - Err(e) => { - println!("Failed to seek start of {:?}: {}", f, e); + let mut checkpoint_number: String = skipback(days).to_string(); + let multi_progress = MultiProgress::new(); + multi_progress.set_draw_target(ProgressDrawTarget::stdout()); + let progress_bar = multi_progress.add(ProgressBar::new(100)); + progress_bar.set_style( + ProgressStyle::default_bar() + .template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len}") + .unwrap(), + ); + progress_bar.enable_steady_tick(std::time::Duration::from_millis(100)); + let client = tracer.in_span("Building HTTP Client", |cx| { + let client_result = build_client(headers); + match client_result { + Ok(client_result) => { + cx.span().add_event( + "info", + vec![KeyValue::new( + "Client Built Successfully", + format!("{:?}", client_result), + )], + ); + client_result + } + Err(client_result) => { + cx.span().add_event( + "warn", + vec![KeyValue::new( + "Client Failed to Build", + format!("{:?}", &client_result), + )], + ); + cx.span() + .set_status(Status::error("Client Failed to Build")); + println!("Failed to Build Client: {:?}", client_result); std::process::abort(); } } - let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { - let results: ApiResponse = history_logging( - py, - &api, - &exec_types, - &checkpoint_number, - &policy_names, - &client, - ); - cx.span().set_attribute(KeyValue::new( - "items_in_response", - results.response.exechistories.len().to_string(), - )); - results - }); - let parsed_responses = execution_histories.response.exechistories; - if parsed_responses.is_empty() { - break; + }); + let cutoff = Local::now().naive_local() - Duration::days(days); + let mut f = match File::open(&writeable_filepath) { + Ok(f) => f, + Err(e) => { + println!("Failed to Access {:?}: {}", &writeable_filepath, e); + std::process::abort(); } - let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() - { - let mut contents = String::new(); - f.read_to_string(&mut contents).unwrap(); - let existing_data: ApiResponse = - serde_json::from_str(&contents).unwrap_or(ApiResponse { - error: "Success".to_string(), - response: ExecHistories { - exechistories: vec![], - }, - }); - existing_data - .response - .exechistories - .into_iter() - .map(|entry| { - ( - ( - entry.sha256.clone(), - entry.filename.clone(), - entry.hostname.clone(), - ), - entry, - ) - }) - .collect() - } else { - HashMap::new() - }; - for (index, executions) in parsed_responses.iter().enumerate() { - if executions.checkpoint.is_empty() || executions.datetime.is_empty() { - continue; + }; + tracer.in_span("Airlock Data Retreival", |cx| { + let span = cx.span(); + span.set_attribute(Key::new("Days").string(days.to_string())); + span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); + loop { + match f.seek(SeekFrom::Start(0)) { + Ok(_) => {} + Err(e) => { + println!("Failed to seek start of {:?}: {}", f, e); + std::process::abort(); + } } - if index == parsed_responses.len() - 1 { - checkpoint_number = executions.checkpoint.clone(); + let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { + let results: ApiResponse = history_logging( + &base_url, + &exec_types, + &checkpoint_number, + &policy_names, + &client, + ); + cx.span().set_attribute(KeyValue::new( + "items_in_response", + results.response.exechistories.len().to_string(), + )); + results + }); + let parsed_responses = execution_histories.response.exechistories; + if parsed_responses.is_empty() { break; } - let history_date = match NaiveDate::parse_from_str( - &executions.datetime.replace(" +0000 UTC", ""), - "%Y-%m-%dT%H:%M:%SZ", - ) { - Ok(date) => date, - Err(_) => continue, + let mut seen: HashMap<(String, String, String), Group> = + if writeable_filepath.exists() { + let mut contents = String::new(); + f.read_to_string(&mut contents).unwrap(); + let existing_data: ApiResponse = + serde_json::from_str(&contents).unwrap_or(ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: vec![], + }, + }); + existing_data + .response + .exechistories + .into_iter() + .map(|entry| { + ( + ( + entry.sha256.clone(), + entry.filename.clone(), + entry.hostname.clone(), + ), + entry, + ) + }) + .collect() + } else { + HashMap::new() + }; + for (index, executions) in parsed_responses.iter().enumerate() { + if executions.checkpoint.is_empty() || executions.datetime.is_empty() { + continue; + } + if index == parsed_responses.len() - 1 { + checkpoint_number = executions.checkpoint.clone(); + break; + } + let history_date = match NaiveDate::parse_from_str( + &executions.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) { + Ok(date) => date, + Err(_) => continue, + }; + if history_date >= cutoff.into() { + let key = ( + executions.sha256.clone(), + executions.filename.clone(), + executions.hostname.clone(), + ); + seen.entry(key).or_insert(executions.clone()); + } + } + let final_response = ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: seen.values().cloned().collect(), + }, }; - if history_date >= cutoff.into() { - let key = ( - executions.sha256.clone(), - executions.filename.clone(), - executions.hostname.clone(), - ); - seen.entry(key).or_insert(executions.clone()); + let data_write = serde_json::to_string_pretty(&final_response).unwrap(); + match fs::write(&writeable_filepath, data_write) { + Ok(_) => {} + Err(e) => { + println!("Failed to write to: {:?}: {}", &writeable_filepath, e); + } + } + if let Some(last_item) = &final_response.response.exechistories.last() + && let Ok(last_date) = NaiveDate::parse_from_str( + &last_item.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) + { + let date_diff = Local::now().naive_local().date() - last_date; + let percentage_diff = + (days - date_diff.num_days()) as f64 / days as f64 * 100.0; + progress_bar.set_position(percentage_diff.round() as u64); + progress_bar.set_message("Total Percent Complete"); } } - let final_response = ApiResponse { - error: "Success".to_string(), - response: ExecHistories { - exechistories: seen.values().cloned().collect(), - }, - }; - let data_write = serde_json::to_string_pretty(&final_response).unwrap(); - match fs::write(&writeable_filepath, data_write) { - Ok(_) => {} - Err(e) => { - println!("Failed to write to: {:?}: {}", &writeable_filepath, e); - } + }); + progress_bar.finish_with_message("All Checkpoints Complete"); + let return_data = match fs::read_to_string(&writeable_filepath) { + Ok(return_data) => return_data, + Err(e) => { + println!("Failed to read data from: {:?}: {}", &writeable_filepath, e); + std::process::abort(); } - if let Some(last_item) = &final_response.response.exechistories.last() - && let Ok(last_date) = NaiveDate::parse_from_str( - &last_item.datetime.replace(" +0000 UTC", ""), - "%Y-%m-%dT%H:%M:%SZ", - ) - { - let date_diff = Local::now().naive_local().date() - last_date; - let percentage_diff = (days - date_diff.num_days()) as f64 / days as f64 * 100.0; - progress_bar.set_position(percentage_diff.round() as u64); - progress_bar.set_message("Total Percent Complete"); - } - } + }; + shutdown_tracer_provider(); + return_data.to_string() }); - progress_bar.finish_with_message("All Checkpoints Complete"); - let return_data = match fs::read_to_string(&writeable_filepath) { - Ok(return_data) => return_data, - Err(e) => { - println!("Failed to read data from: {:?}: {}", &writeable_filepath, e); - std::process::abort(); - } - }; - shutdown_tracer_provider(); - PyString::new(py, &return_data).into() + let gil_value = handle.join().unwrap(); + Python::attach(|py| PyString::new(py, &gil_value).into()) } -fn build_client(py: Python<'_>, py_self: &Py) -> Result { - let headers = py_self.getattr(py, "headers").unwrap().to_string(); - let headers_replace = headers.replace('\'', "\""); - let parsed: Value = serde_json::from_str(headers_replace.as_str()).unwrap(); - let mut header_map = HeaderMap::new(); - if let Some(obj) = parsed.as_object() { - for (_key, value) in obj { - if let Some(v) = value.as_str() { - let val = HeaderValue::from_str(v).unwrap(); - header_map.insert(HeaderName::from_str("X-APIKey").unwrap(), val); - } - } - } - +fn build_client(headers: HeaderMap) -> Result { Client::builder() .danger_accept_invalid_certs(true) - .default_headers(header_map) + .default_headers(headers) .timeout(std::time::Duration::from_secs(300)) .build() } #[tokio::main] async fn history_logging( - py: Python<'_>, - py_self: &Py, + base_url: &String, exec_types: &String, checkpoint_number: &String, policy_names: &String, client: &Client, ) -> ApiResponse { - let base_url = py_self.getattr(py, "base_url").unwrap().to_string(); let payload = format!( r#"{{ "type": {}, diff --git a/requirements.txt b/requirements.txt index 705d525..336bdd8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==4.0.3 \ No newline at end of file +airlock_libs==5.0.0 \ No newline at end of file -- 2.34.1 From 1d9caadaf3af788580e1d7c388479e8ca1cc26b4 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Tue, 2 Dec 2025 16:22:43 -0500 Subject: [PATCH 08/25] Major step towards unification of the UI Implementation of the Back Feature, splitting of TUI files back into subfolders --- Loxide.py | 3 +- TUI/{ => Screens}/allowlistselectionscreen.py | 55 +- TUI/Screens/moveagentworkflowscreen.py | 114 ++ TUI/{ => Screens}/otpactivityscreen.py | 110 +- TUI/Screens/otprevokescreen.py | 393 ++++ TUI/Screens/otpworkflowscreen.py | 52 + TUI/Screens/policyprepworkflowscreen.py | 1822 +++++++++++++++++ TUI/{ => Screens}/policyselectorscreen.py | 20 +- TUI/{ => Screens}/quietagentworkflowscreen.py | 101 +- TUI/TUI.py | 193 +- TUI/{ => Themes}/theme_amber_terminal.py | 15 + TUI/{ => Themes}/theme_retro_terminal.py | 15 + TUI/{ => Themes}/themeselector.py | 15 + TUI/{ => Widgets}/OTP_generate.py | 36 +- TUI/{ => Widgets}/agentmoveoperations.py | 87 +- TUI/{ => Widgets}/multiagentselector.py | 31 +- TUI/{ => Widgets}/policyselector.py | 56 +- TUI/{ => Widgets}/policytreewidget.py | 15 + TUI/Widgets/prepPolicy.py | 870 ++++++++ TUI/{ => Widgets}/resultsdisplay.py | 52 +- TUI/moveagentworkflowscreen.py | 61 - TUI/otpworkflowscreen.py | 24 - flows/localApproval.py | 65 +- flows/prepPolicy.py | 46 +- models/execution.py | 8 +- services/agenthandler.py | 28 +- services/policyhandler.py | 17 +- utils/selector.py | 12 +- utils/setup.py | 6 +- 29 files changed, 3761 insertions(+), 561 deletions(-) rename TUI/{ => Screens}/allowlistselectionscreen.py (93%) create mode 100644 TUI/Screens/moveagentworkflowscreen.py rename TUI/{ => Screens}/otpactivityscreen.py (92%) create mode 100644 TUI/Screens/otprevokescreen.py create mode 100644 TUI/Screens/otpworkflowscreen.py create mode 100644 TUI/Screens/policyprepworkflowscreen.py rename TUI/{ => Screens}/policyselectorscreen.py (82%) rename TUI/{ => Screens}/quietagentworkflowscreen.py (91%) rename TUI/{ => Themes}/theme_amber_terminal.py (55%) rename TUI/{ => Themes}/theme_retro_terminal.py (50%) rename TUI/{ => Themes}/themeselector.py (68%) rename TUI/{ => Widgets}/OTP_generate.py (92%) rename TUI/{ => Widgets}/agentmoveoperations.py (90%) rename TUI/{ => Widgets}/multiagentselector.py (88%) rename TUI/{ => Widgets}/policyselector.py (92%) rename TUI/{ => Widgets}/policytreewidget.py (93%) create mode 100644 TUI/Widgets/prepPolicy.py rename TUI/{ => Widgets}/resultsdisplay.py (74%) delete mode 100644 TUI/moveagentworkflowscreen.py delete mode 100644 TUI/otpworkflowscreen.py diff --git a/Loxide.py b/Loxide.py index fae9cc8..7c3e974 100644 --- a/Loxide.py +++ b/Loxide.py @@ -30,7 +30,7 @@ from services.API import AirlockAPIWrapper from services.security import getAPI from TUI.TUI import run_Loxide from utils.configmanager import get_system_value -from utils.setup import get_base_directory, setup +from utils.setup import setup from utils.utils import irtang urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -40,7 +40,6 @@ def main(): irtang() # Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored setup() - base_dir = get_base_directory() logger = logging.getLogger(__name__) try: diff --git a/TUI/allowlistselectionscreen.py b/TUI/Screens/allowlistselectionscreen.py similarity index 93% rename from TUI/allowlistselectionscreen.py rename to TUI/Screens/allowlistselectionscreen.py index 72426f2..cb04f2d 100644 --- a/TUI/allowlistselectionscreen.py +++ b/TUI/Screens/allowlistselectionscreen.py @@ -1,3 +1,17 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . from __future__ import annotations import logging @@ -150,14 +164,11 @@ class AllowlistSelectionWidget(Static): # Action buttons at bottom with Horizontal(id="action_buttons"): - self.back_btn = Button("β¬… Back", id="back_btn") self.add_btn = Button("βž• Add to Allowlist", id="add_to_allowlist_btn") - self.back_btn.styles.width = "50%" - self.add_btn.styles.width = "50%" + self.add_btn.styles.width = "100%" self.add_btn.disabled = True # Disabled until allowlist selected - yield self.back_btn yield self.add_btn async def on_mount(self) -> None: @@ -380,9 +391,9 @@ class AllowlistSelectionWidget(Static): if found_col: self.hash_column = found_col - preview_lines.append(f"ΓƒΒ’Γ…Β“Γ’Β€Βœ Found hash column: **{found_col}**\n") + preview_lines.append(f"βœ… Found hash column: **{found_col}**\n") else: - preview_lines.append("Òő ï¸ **No hash column found**\n") + preview_lines.append("❌ **No hash column found**\n") preview_lines.append("Available columns:\n") for col in self.selected_data.columns: if col != "_row_id": @@ -464,7 +475,7 @@ class AllowlistSelectionWidget(Static): self.selected_allowlist = self.allowlists[actual_allowlist_index] self.add_btn.disabled = False self.add_btn.label = ( - f"Γ’ΒžΒ• Add to '{self.selected_allowlist.get('name', 'Unknown')}'" + f"βž• Add to '{self.selected_allowlist.get('name', 'Unknown')}'" ) # Update preview with selection @@ -523,11 +534,6 @@ class AllowlistSelectionWidget(Static): btn = getattr(event, "button", None) or getattr(event, "sender", None) btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None) - if btn is self.back_btn or btn_id == "back_btn": - await self.app.pop_screen() - event.stop() - return - if btn is self.refresh_btn or btn_id == "refresh_allowlists_btn": await self.load_allowlists() event.stop() @@ -553,7 +559,7 @@ class AllowlistSelectionWidget(Static): try: # Disable button during operation self.add_btn.disabled = True - self.add_btn.label = "⏳ Adding hashes..." + self.add_btn.label = "Adding hashes..." # Call API to add hashes app_id = self.selected_allowlist.get("applicationid") @@ -564,10 +570,10 @@ class AllowlistSelectionWidget(Static): ) result = self.api.hash_add_to_allowlist(app_id, self.hashes_to_add) - + logger.debug(f"Hash adding api call: {result}") # Success notification self.app.notify( - f"βœ… Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'", + f"Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'", title="Success", severity="information", timeout=5, @@ -575,7 +581,7 @@ class AllowlistSelectionWidget(Static): # Update preview to show success self.preview_area.text = ( - f"## βœ… SUCCESS\n\n" + f"## SUCCESS\n\n" f"Added **{len(self.hashes_to_add)} hashes** to allowlist:\n" f"**{allowlist_name}** (ID: {app_id})\n\n" f"### Operation Details:\n" @@ -586,13 +592,13 @@ class AllowlistSelectionWidget(Static): ) # Change button to "Done" - self.add_btn.label = "βœ… Done" + self.add_btn.label = "Done - Press q to return to main menu" self.add_btn.disabled = True except Exception as exc: logger.exception(f"Failed to add hashes to allowlist: {exc}") self.app.notify( - f"❌ Failed to add hashes: {str(exc)}", + f"Failed to add hashes: {str(exc)}", title="Error", severity="error", timeout=10, @@ -600,7 +606,7 @@ class AllowlistSelectionWidget(Static): # Re-enable button self.add_btn.disabled = False - self.add_btn.label = "⟳ Retry Add to Allowlist" + self.add_btn.label = "Retry Add to Allowlist" class AllowlistSelectionScreen(Screen): @@ -609,9 +615,9 @@ class AllowlistSelectionScreen(Screen): """ BINDINGS = [ - Binding("b", "back", "Back"), + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), Binding("r", "refresh", "Refresh Allowlists"), - Binding("enter", "confirm", "Add to Allowlist"), ] def __init__( @@ -641,10 +647,15 @@ class AllowlistSelectionScreen(Screen): yield self.widget yield Footer() - async def action_back(self) -> None: + async def action_go_back(self) -> None: """Go back to previous screen.""" await self.app.pop_screen() + async def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + await self.app.pop_screen() + async def action_refresh(self) -> None: """Refresh the allowlists.""" if hasattr(self, "widget") and self.widget: diff --git a/TUI/Screens/moveagentworkflowscreen.py b/TUI/Screens/moveagentworkflowscreen.py new file mode 100644 index 0000000..16dbfba --- /dev/null +++ b/TUI/Screens/moveagentworkflowscreen.py @@ -0,0 +1,114 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import List, Optional + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.css.query import NoMatches +from textual.screen import Screen + +from models.agent import Agent +from TUI.Widgets.agentmoveoperations import AgentMoveOperations +from TUI.Widgets.multiagentselector import MultiAgentSelector +from TUI.Widgets.resultsdisplay import ResultsDisplay + + +class MoveAgentWorkflowScreen(Screen): + """Screen that handles the agent movement workflow.""" + + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), + ] + + def __init__(self, all_agents: Optional[List[Agent]]): + super().__init__() + self.all_agents = all_agents + self.selected_agents = None + self.workflow_stage = "select_agents" # Track current stage + + def compose(self) -> ComposeResult: + """Start with the multi-agent selector.""" + yield MultiAgentSelector(self.all_agents) + + def action_go_back(self) -> None: + """Handle escape key to go back one step within the workflow.""" + if self.workflow_stage == "select_agents": + # At first stage, go back to main menu + self.app.pop_screen() + elif self.workflow_stage == "operations": + # Go back to agent selection + try: + ops_widget = self.query_one(AgentMoveOperations) + ops_widget.remove() + except NoMatches: + pass + self.mount(MultiAgentSelector(self.all_agents)) + self.workflow_stage = "select_agents" + elif self.workflow_stage == "results": + # Go back to operations + try: + results_widget = self.query_one(ResultsDisplay) + results_widget.remove() + except NoMatches: + pass + self.mount(AgentMoveOperations(self.selected_agents)) + self.workflow_stage = "operations" + + def action_main_menu(self) -> None: + """Handle q key to go back to main menu.""" + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + + def on_multi_agent_selector_agents_selected( + self, message: MultiAgentSelector.AgentsSelected + ) -> None: + """Handle selected agents - switch to operations screen.""" + self.selected_agents = message.selected_agents + + # Remove the MultiAgentSelector + selector = self.query_one(MultiAgentSelector) + selector.remove() + + # Mount the AgentMoveOperations with the selected Agent objects + self.mount(AgentMoveOperations(self.selected_agents)) + self.workflow_stage = "operations" + + def on_agent_move_operations_operation_complete( + self, message: AgentMoveOperations.OperationComplete + ) -> None: + """Handle completion of move operation - transition to results screen.""" + # Format successful results + success_lines = [] + for agent, result in message.successful: + success_lines.append(f"βœ” {agent.hostname}") + + # Format unsuccessful results + failure_lines = [] + for agent, error in message.unsuccessful: + failure_lines.append(f"❌ β€” {agent.hostname}: {error}") + + successful_text = "\n".join(success_lines) if success_lines else "(none)" + unsuccessful_text = "\n".join(failure_lines) if failure_lines else "(none)" + + # Remove the operations widget + ops_widget = self.query_one(AgentMoveOperations) + ops_widget.remove() + + # Mount the results display + self.mount( + ResultsDisplay(message.operation, successful_text, unsuccessful_text) + ) + self.workflow_stage = "results" diff --git a/TUI/otpactivityscreen.py b/TUI/Screens/otpactivityscreen.py similarity index 92% rename from TUI/otpactivityscreen.py rename to TUI/Screens/otpactivityscreen.py index 5947cee..3b6714f 100644 --- a/TUI/otpactivityscreen.py +++ b/TUI/Screens/otpactivityscreen.py @@ -1,3 +1,17 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . from __future__ import annotations from datetime import datetime @@ -11,7 +25,7 @@ from textual.containers import Horizontal, Vertical from textual.screen import Screen from textual.widgets import Button, DataTable, Footer, Header, Static -from TUI.allowlistselectionscreen import AllowlistSelectionScreen +from TUI.Screens.allowlistselectionscreen import AllowlistSelectionScreen from utils.configmanager import load_env logger = logging.getLogger(__name__) @@ -31,8 +45,7 @@ class OTPActivitiesWidget(Static): """ Reusable widget that contains the sessions table (left) and an Activity Preview (right). The right side shows an Activity Preview that takes ~75% vertical space, and a lower area - with Back and Continue buttons. The Continue button pushes ActivityDetailScreen with the - currently-loaded activities. + with Continue button. """ DEFAULT_CSS = """ @@ -85,15 +98,10 @@ class OTPActivitiesWidget(Static): with Vertical(id="activity_preview_container"): self.activities_table = DataTable(id="activity_preview_table") yield self.activities_table - # Buttons area at the bottom (Back, Continue) + # Button area at the bottom (Continue) with Horizontal(id="activity_buttons"): - # Back takes left side, Continue right side - self.back_btn = Button("Back", id="activity_back_btn") self.continue_btn = Button("Continue", id="activity_continue_btn") - # Stretch buttons nicely - self.back_btn.styles.width = "50%" - self.continue_btn.styles.width = "50%" - yield self.back_btn + self.continue_btn.styles.width = "100%" yield self.continue_btn async def on_mount(self) -> None: @@ -122,7 +130,7 @@ class OTPActivitiesWidget(Static): async def on_button_pressed(self, event) -> None: # type: ignore[override] """ - Handle Back / Continue buttons for the Activity Preview area. + Handle Continue button for the Activity Preview area. """ # Try to resolve the button object from the event btn = ( @@ -136,18 +144,12 @@ class OTPActivitiesWidget(Static): or getattr(event, "button_id", None) or getattr(event, "id", None) ) - # ---- Back ---- - if btn is self.back_btn or btn_id == getattr(self.back_btn, "id", None): - while len(self.app.screen_stack) > 2: - self.app.pop_screen() - event.stop() - return # ---- Continue ---- if btn is self.continue_btn or btn_id == getattr(self.continue_btn, "id", None): if self._activities_df is None or self._activities_df.empty: logger.info("Continue pressed but no activities loaded.") - await self.post_message( - Static("No activities loaded to continue with.") + self.app.notify( + "No activities loaded to continue with.", severity="warning" ) return # Copy activities DataFrame to pass to new screen @@ -463,7 +465,7 @@ class OTPActivitiesWidget(Static): try: self._activities_df.to_csv(file_path, index=False) logger.info("Exported activities to %s", file_path) - await self.post_message(Static(f"βœ… Exported activities to: {file_path}")) + await self.post_message(Static(f"Exported activities to: {file_path}")) except Exception as exc: logger.exception("Failed to export activities to %s: %s", file_path, exc) await self.post_message(Static("Failed to export activities; check logs.")) @@ -472,7 +474,7 @@ class OTPActivitiesWidget(Static): class ActivityDetailWidget(Static): """ Interactive widget for Activity Detail screen. - Shows the provided DataFrame in a DataTable and offers Export + Back buttons. + Shows the provided DataFrame in a DataTable and offers Export button. Now includes Select All/None and Add to Allowlist functionality. """ @@ -529,12 +531,10 @@ class ActivityDetailWidget(Static): # Original buttons at bottom with Horizontal(id="detail_buttons"): - self.detail_back_btn = Button("Back", id="detail_back_btn") self.add_allowlist_btn = Button( - "πŸ“‹ Add Selected to Allowlist", id="add_allowlist_btn" + "Add Selected to Allowlist", id="add_allowlist_btn" ) yield self.add_allowlist_btn - yield self.detail_back_btn async def on_mount(self) -> None: await self._build_table(rebuild=True) @@ -547,12 +547,12 @@ class ActivityDetailWidget(Static): # Update button labels with count count = len(self.selected_row_ids) - total = len(self.activities_df) + len(self.activities_df) if has_selection: - self.add_allowlist_btn.label = f"πŸ“‹ Add {count} Selected to Allowlist" + self.add_allowlist_btn.label = f"Add {count} Selected to Allowlist" else: - self.add_allowlist_btn.label = "πŸ“‹ Add Selected to Allowlist" + self.add_allowlist_btn.label = "Add Selected to Allowlist" async def _build_table(self, rebuild: bool = True) -> None: """Rebuild the DataTable. If rebuild=False, only refresh rows.""" @@ -591,7 +591,7 @@ class ActivityDetailWidget(Static): vals.append("" if pd.isna(v) else str(v)) # Check if this row is selected - checkbox = "β˜‘" if row_id in self.selected_row_ids else "☐" + checkbox = "β˜‘οΈ" if row_id in self.selected_row_ids else "☐" # Add row to table row_key = self.detail_table.add_row(checkbox, *vals) @@ -616,7 +616,7 @@ class ActivityDetailWidget(Static): self.detail_table.update_cell(row_key, "select", "☐") # Unchecked else: self.selected_row_ids.add(row_id) - self.detail_table.update_cell(row_key, "select", "β˜‘") # Checked + self.detail_table.update_cell(row_key, "select", "β˜‘οΈ") # Checked self._update_button_states() @@ -652,19 +652,13 @@ class ActivityDetailWidget(Static): logger.exception("Failed to sort by column %s: %s", column_key, exc) return - # Òœ… Only refresh rows, not columns + # Only refresh rows, not columns await self._build_table(rebuild=False) async def on_button_pressed(self, event) -> None: btn = getattr(event, "button", None) or getattr(event, "sender", None) btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None) - if btn is self.detail_back_btn or btn_id == "detail_back_btn": - while len(self.app.screen_stack) > 2: - self.app.pop_screen() - event.stop() - return - if btn is self.add_allowlist_btn or btn_id == "add_allowlist_btn": await self._open_allowlist_screen() return @@ -676,7 +670,7 @@ class ActivityDetailWidget(Static): # Update all checkboxes in the table for row_key, row_id in self.row_key_to_id.items(): - self.detail_table.update_cell(row_key, "select", "β˜‘") + self.detail_table.update_cell(row_key, "select", "β˜‘οΈ") self._update_button_states() logger.info(f"Selected all {len(self.selected_row_ids)} rows") @@ -688,7 +682,7 @@ class ActivityDetailWidget(Static): # Update all checkboxes in the table for row_key, row_id in self.row_key_to_id.items(): - self.detail_table.update_cell(row_key, "select", "☐") + self.detail_table.update_cell(row_key, "select", "β˜‘οΈ") self._update_button_states() logger.info("Cleared all selections") @@ -730,14 +724,12 @@ class ActivityDetailWidget(Static): async def _export_detail_activities(self) -> None: if self.activities_df is None or self.activities_df.empty: logger.info("No activities to export.") - await self.mount( - Static("ҝŒ No activities to export.", classes="notification") - ) + await self.mount(Static("No activities to export.", classes="notification")) return if not self.selected_row_ids: logger.info("No rows selected for export.") await self.mount( - Static("ҝŒ No rows selected for export.", classes="notification") + Static("No rows selected for export.", classes="notification") ) return try: @@ -750,7 +742,7 @@ class ActivityDetailWidget(Static): logger.info("Exported selected activities to %s", file_path) await self.mount( Static( - f"βœ… Exported selected activities to: {filename}", + f"Exported selected activities to: {filename}", classes="notification", ) ) @@ -758,12 +750,12 @@ class ActivityDetailWidget(Static): logger.exception("Failed to export detail activities: %s", exc) await self.mount( Static( - "ҝŒ Failed to export activities; check logs.", + "’ Failed to export activities; check logs.", classes="notification", ) ) - # Òœ… Helper methods + # Helper methods def get_selected_data(self) -> pd.DataFrame: """Return a DataFrame of the selected rows.""" if not self.selected_row_ids: @@ -795,7 +787,8 @@ class ActivityDetailScreen(Screen): """ BINDINGS = [ - Binding("b", "back", "Back"), + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), Binding("e", "export", "Export"), Binding("a", "select_all", "Select All"), Binding("n", "select_none", "Select None"), @@ -819,11 +812,16 @@ class ActivityDetailScreen(Screen): yield self.widget yield Footer() - async def action_back(self) -> None: + async def action_go_back(self) -> None: try: await self.app.pop_screen() except Exception: - logger.debug("ActivityDetailScreen.action_back pop_screen failed.") + logger.debug("ActivityDetailScreen.action_go_back pop_screen failed.") + + async def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + await self.app.pop_screen() async def action_export(self) -> None: # Delegate to widget export helper @@ -851,9 +849,10 @@ class OTPActivitiesScreen(Screen): """ BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), Binding("r", "refresh_sessions", "Refresh Sessions"), Binding("e", "export_activities", "Export activities"), - Binding("q", "quit", "Quit"), ] def compose(self) -> ComposeResult: @@ -875,6 +874,15 @@ class OTPActivitiesScreen(Screen): else: await self.widget.load_sessions_from_api(api) + async def action_go_back(self) -> None: + """Go back one screen.""" + await self.app.pop_screen() + + async def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + await self.app.pop_screen() + # Simple actions bound to keys async def action_refresh_sessions(self) -> None: api = getattr(self.app, "api", None) @@ -884,10 +892,6 @@ class OTPActivitiesScreen(Screen): logger.info("Refreshing OTP sessions via API.") await self.widget.load_sessions_from_api(api) - async def action_quit(self) -> None: - # Pop the screen or exit app - await self.app.pop_screen() - # If you want an explicit method to fetch activities for a particular otpid from outside: async def fetch_activities_for_otpid(self, otpid, hostname=None) -> None: api = getattr(self.app, "api", None) diff --git a/TUI/Screens/otprevokescreen.py b/TUI/Screens/otprevokescreen.py new file mode 100644 index 0000000..5dc142d --- /dev/null +++ b/TUI/Screens/otprevokescreen.py @@ -0,0 +1,393 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from __future__ import annotations + +import logging +from typing import List, Optional + +import pandas as pd +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.message import Message +from textual.screen import Screen +from textual.widgets import Button, DataTable, Footer, Header, Static + +logger = logging.getLogger(__name__) + + +class OTPRevokeWidget(Static): + """ + Widget for managing OTP session revocation. + Displays active OTP sessions and allows selection for revocation. + """ + + class SessionsRevoked(Message): + """Message sent when sessions are revoked.""" + + def __init__(self, revoked_sessions: List[dict]): + super().__init__() + self.revoked_sessions = revoked_sessions + + DEFAULT_CSS = """ + OTPRevokeWidget { + height: 1fr; + } + #main_container { + width: 100%; + height: 100%; + layout: vertical; + } + #sessions_container { + height: 1fr; + border: none; + padding: 1; + } + #button_container { + height: auto; + padding: 1; + align: center middle; + } + #result_container { + height: auto; + max-height: 10; + border: solid #444444; + padding: 1; + margin: 1; + overflow-y: auto; + } + .panel-title { + text-style: bold; + margin: 0 0 1 0; + } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="main_container"): + # Sessions table + yield Static("OTP Sessions", classes="panel-title") + with Vertical(id="sessions_container"): + self.sessions_table = DataTable(id="sessions_table") + self.sessions_table.styles.width = "100%" + self.sessions_table.styles.height = "1fr" + yield self.sessions_table + + # Action buttons + with Horizontal(id="button_container"): + self.refresh_button = Button("πŸ”„ Refresh", id="refresh_btn") + self.refresh_button.styles.width = "15%" + self.refresh_button.styles.margin = (1, 1, 1, 1) + yield self.refresh_button + + self.select_all_button = Button("β˜‘οΈ Select All", id="select_all_btn") + self.select_all_button.styles.width = "15%" + self.select_all_button.styles.margin = (1, 1, 1, 1) + yield self.select_all_button + + self.select_none_button = Button( + "❌ Clear Selection", id="select_none_btn" + ) + self.select_none_button.styles.width = "20%" + self.select_none_button.styles.margin = (1, 1, 1, 1) + yield self.select_none_button + + self.revoke_button = Button( + "πŸ›‘ Revoke Selected", id="revoke_btn", variant="error" + ) + self.revoke_button.styles.width = "20%" + self.revoke_button.styles.margin = (1, 1, 1, 1) + yield self.revoke_button + + # Results display + with Vertical(id="result_container"): + yield Static("Revocation Results", classes="panel-title") + self.results_display = Static("No actions performed yet.") + yield self.results_display + + async def on_mount(self) -> None: + """Initialize the widget when mounted.""" + # Configure sessions table + self.sessions_table.clear() + self.sessions_table.add_columns( + "☐", "OTP ID", "Hostname", "Status", "Purpose", "Granted" + ) + + # Enable row selection with checkbox column + self.sessions_table.cursor_type = "row" + try: + self.sessions_table.zebra_stripes = True + except Exception: + pass + + # Initialize state + self._sessions_df: Optional[pd.DataFrame] = None + self._filtered_df: Optional[pd.DataFrame] = None + self._selected_otpids: set = set() + + async def load_sessions_from_api(self, api) -> None: + """Load active OTP sessions from the API.""" + try: + # Fetch only active sessions + active_df = api.otp_find_active() + + # Ensure we have a DataFrame + if not isinstance(active_df, pd.DataFrame): + active_df = pd.DataFrame(active_df) + + # Add status column + active_df["status"] = "active" + + # Sort by otpid if column exists + if "otpid" in active_df.columns and not active_df.empty: + active_df = active_df.sort_values(by="otpid", ascending=False) + + # Store the full dataframe + self._sessions_df = active_df + self._filtered_df = active_df.copy() + + # Display in table + await self._refresh_table() + + # Update status + active_count = len(active_df) + + status_msg = f"Loaded {active_count} active sessions" + logger.info(status_msg) + self.results_display.update(status_msg) + + except Exception as e: + logger.exception(f"Failed to load OTP sessions: {e}") + self.results_display.update(f"Error loading sessions: {str(e)}") + + async def _refresh_table(self) -> None: + """Refresh the table display with current filtered data.""" + if self._filtered_df is None or self._filtered_df.empty: + self.sessions_table.clear() + return + + # Ensure expected columns exist + expected_cols = ["otpid", "hostname", "status", "purpose", "granted"] + for col in expected_cols: + if col not in self._filtered_df.columns: + self._filtered_df[col] = "" + + # Clear and repopulate table + self.sessions_table.clear(columns=False) + + for _, row in self._filtered_df.iterrows(): + otpid = str(row.get("otpid", "")) + # Check if this row is selected + checkbox = "β˜‘οΈ" if otpid in self._selected_otpids else "☐" + + self.sessions_table.add_row( + checkbox, + str(otpid), + str(row.get("hostname", "")), + str(row.get("status", "")), + str(row.get("purpose", "")), + str(row.get("granted", "")), + ) + + async def on_button_pressed(self, event) -> None: + """Handle button presses.""" + btn = event.button + + if btn.id == "refresh_btn": + # Refresh sessions + api = getattr(self.app, "api", None) + if api: + await self.load_sessions_from_api(api) + + elif btn.id == "select_all_btn": + # Select all visible rows + if self._filtered_df is not None: + self._selected_otpids = set(str(x) for x in self._filtered_df["otpid"]) + await self._refresh_table() + + elif btn.id == "select_none_btn": + # Clear selection + self._selected_otpids.clear() + await self._refresh_table() + + elif btn.id == "revoke_btn": + # Revoke selected sessions + await self._revoke_selected() + + async def on_data_table_row_selected(self, event) -> None: + """Handle row selection in the table.""" + if event.data_table != self.sessions_table: + return + + try: + # Get the row index from the cursor row + row_index = self.sessions_table.cursor_row + + if self._filtered_df is not None and row_index < len(self._filtered_df): + # Get the OTP ID for this row + otpid = str(self._filtered_df.iloc[row_index]["otpid"]) + + # Toggle selection + if otpid in self._selected_otpids: + self._selected_otpids.remove(otpid) + else: + self._selected_otpids.add(otpid) + + # Refresh table to update checkbox + await self._refresh_table() + + # Restore cursor position + self.sessions_table.move_cursor(row=row_index) + + except Exception as e: + logger.exception(f"Error handling row selection: {e}") + + async def _revoke_selected(self) -> None: + """Revoke the selected OTP sessions.""" + if not self._selected_otpids: + self.results_display.update("❌ No sessions selected for revocation") + return + + api = getattr(self.app, "api", None) + if not api: + self.results_display.update("❌ API not available") + return + + # Collect results + results = [] + success_count = 0 + failure_count = 0 + + for otpid in self._selected_otpids: + try: + # Get hostname for this session + hostname = "Unknown" + if self._sessions_df is not None: + # Convert otpid to same type as in DataFrame for comparison + otpid_compare = otpid + if len(self._sessions_df) > 0: + first_otpid = self._sessions_df["otpid"].iloc[0] + if isinstance(first_otpid, int): + otpid_compare = int(otpid) + + match = self._sessions_df[ + self._sessions_df["otpid"] == otpid_compare + ] + if not match.empty: + hostname = match.iloc[0].get("hostname", "Unknown") + + # Revoke the session + result = api.otp_revoke(otpid) + + if result and result.get("status") != "error": + success_count += 1 + results.append(f"Revoked OTP {otpid} for {hostname}") + logger.info(f"Revoked OTP {otpid} for {hostname}: {result}") + else: + failure_count += 1 + error_msg = ( + result.get("message", "Unknown error") + if result + else "No response" + ) + results.append( + f"❌ Failed to revoke OTP {otpid} for {hostname}: {error_msg}" + ) + logger.error(f"Failed to revoke OTP {otpid}: {error_msg}") + + except Exception as e: + failure_count += 1 + results.append(f"❌ Error revoking OTP {otpid}: {str(e)}") + logger.exception(f"Exception revoking OTP {otpid}: {e}") + + # Update results display + summary = ( + f"Revocation complete: {success_count} succeeded, {failure_count} failed\n" + ) + details = "\n".join(results[-5:]) # Show last 5 results + if len(results) > 5: + details = f"... (showing last 5 of {len(results)} results)\n" + details + + self.results_display.update(summary + details) + + # Clear selection and refresh + self._selected_otpids.clear() + await self.load_sessions_from_api(api) + + # Post message about revoked sessions + if success_count > 0: + self.post_message(self.SessionsRevoked(results)) + + +class OTPRevokeScreen(Screen): + """ + Main screen for OTP session revocation workflow. + This replaces the otp_revoke function from otp.py. + """ + + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), + Binding("r", "refresh", "Refresh"), + Binding("a", "select_all", "Select All"), + Binding("n", "select_none", "Clear Selection"), + Binding("d", "revoke", "Revoke Selected"), + ] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + self.widget = OTPRevokeWidget() + yield self.widget + yield Footer() + + async def on_mount(self) -> None: + """Load sessions when screen mounts.""" + api = getattr(self.app, "api", None) + if api: + await self.widget.load_sessions_from_api(api) + else: + logger.warning("OTPRevokeScreen mounted but no self.app.api found.") + + async def action_refresh(self) -> None: + """Refresh the sessions list.""" + api = getattr(self.app, "api", None) + if api: + await self.widget.load_sessions_from_api(api) + + async def action_select_all(self) -> None: + """Select all visible sessions.""" + if self.widget._filtered_df is not None: + self.widget._selected_otpids = set( + str(x) for x in self.widget._filtered_df["otpid"] + ) + await self.widget._refresh_table() + + async def action_select_none(self) -> None: + """Clear all selections.""" + self.widget._selected_otpids.clear() + await self.widget._refresh_table() + + async def action_revoke(self) -> None: + """Revoke selected sessions.""" + await self.widget._revoke_selected() + + async def action_go_back(self) -> None: + """Go back to previous screen.""" + await self.app.pop_screen() + + async def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + await self.app.pop_screen() diff --git a/TUI/Screens/otpworkflowscreen.py b/TUI/Screens/otpworkflowscreen.py new file mode 100644 index 0000000..124569c --- /dev/null +++ b/TUI/Screens/otpworkflowscreen.py @@ -0,0 +1,52 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from typing import List, Optional + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.screen import Screen + +from models.agent import Agent +from TUI.Widgets.OTP_generate import OTPGenerator + + +class OTPWorkflowScreen(Screen): + """Screen that handles the OTP generation workflow without agent selection.""" + + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), + ] + + def __init__(self, selected_agents: Optional[List[Agent]]): + super().__init__() + self.selected_agents = selected_agents + + def compose(self) -> ComposeResult: + """Directly show the OTP generator for the selected agents.""" + yield OTPGenerator(self.selected_agents) + + def action_go_back(self) -> None: + """Handle escape key to go back one screen.""" + self.app.pop_screen() + + def action_main_menu(self) -> None: + """Handle q key to go back to main menu.""" + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + + def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: + """Handle OTP generation request - pass it up to the app level if needed.""" diff --git a/TUI/Screens/policyprepworkflowscreen.py b/TUI/Screens/policyprepworkflowscreen.py new file mode 100644 index 0000000..7035dc2 --- /dev/null +++ b/TUI/Screens/policyprepworkflowscreen.py @@ -0,0 +1,1822 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import datetime +import logging +import os +from typing import Dict, List, Optional + +import pandas as pd +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import Button, DataTable, Footer, Header, Input, Static + +from models.execution import ExecutionHistoryRecord +from models.policy import Allowlist, Policy +from services.API import AirlockAPIWrapper +from TUI.Widgets.policyselector import PolicySelector +from utils.configmanager import get_system_list, get_system_value, load_env +from utils.utils import formatHTML + +logger = logging.getLogger(__name__) + + +class PolicyPrepWorkflowScreen(Screen): + """ + A Textual screen for the Policy Preparation workflow. + + This screen provides a multi-step workflow: + 1. Select source policies to gather execution data from + 2. Select destination policy and associated allowlist + 3. Fetch and sort execution history + 4. Manual review of approved/needs_review files + 5. Generate path exclusions and publisher lists + 6. Second manual review of paths/publishers + 7. Test - preview changes + 8. Liftoff - apply changes + + Attributes: + api (AirlockAPIWrapper): API wrapper for Airlock operations + policies (List[Policy]): List of all available policies + source_policies (List[Policy]): Selected source policies + destination_policy (Optional[Policy]): Destination policy + destination_allowlist (Optional[Allowlist]): Associated allowlist + workflow_stage (str): Current stage of the workflow + working_dir (str): Working directory for exports + """ + + DEFAULT_CSS = """ + DataTable > .datatable--row.selected { + background: $primary 30%; + } + + DataTable:focus > .datatable--cursor { + background: $secondary 20%; + } + + #workflow_title { + text-style: bold; + color: $text; + } + + #workflow_status { + color: $accent; + } + + #checklist_area { + max-height: 30%; + margin: 0 1 0 1; + } + + #content_area { + height: 1fr; + } + + Button.variant-error { + background: $error; + color: $text; + } + + Button.variant-success { + background: $success; + color: $text; + } + """ + + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), + Binding("f", "open_folder", "Open Folder"), + Binding("r", "refresh", "Refresh"), + Binding("d", "delete_rows", "Delete Selected"), + Binding("a", "select_all", "Select All"), + Binding("n", "select_none", "Select None"), + Binding("space", "toggle_selection", "Toggle Selection", show=False), + ] + + workflow_stage = reactive("select_source") # Tracks current workflow stage + + def __init__(self, api: AirlockAPIWrapper, policies: List[Policy]): + """ + Initialize the PolicyPrepWorkflowScreen. + + Args: + api (AirlockAPIWrapper): API wrapper for Airlock operations + policies (List[Policy]): List of all available policies + """ + super().__init__() + self.api = api + self.policies = policies + self.source_policies: List[Policy] = [] + self.destination_policy: Optional[Policy] = None + self.destination_allowlist: Optional[Allowlist] = None + self.working_dir = load_env("WORKING_DIR") or os.getcwd() + self.history_days: Optional[int] = None + self.path_split: str = "\\\\" # Default path split for Windows + + # Data storage + self.approved_df: Optional[pd.DataFrame] = None + self.needs_review_df: Optional[pd.DataFrame] = None + self.unapproved_df: Optional[pd.DataFrame] = None + self.primary_paths_df: Optional[pd.DataFrame] = None + self.secondary_paths_df: Optional[pd.DataFrame] = None + self.publishers_df: Optional[pd.DataFrame] = None + self.remaining_hashes_df: Optional[pd.DataFrame] = None + + # Test data for preview + self.test_results: Optional[Dict] = None + + def compose(self) -> ComposeResult: + """Build the UI layout for the workflow screen.""" + yield Header(show_clock=True, icon="βš™οΈ") + + # Title area + title = Static("Policy Preparation Workflow", id="workflow_title") + title.styles.text_align = "center" + title.styles.margin = (0, 0, 0, 1) + yield title + + # Status area + status = Static("Step 1: Select Source Policies", id="workflow_status") + status.styles.margin = (0, 0, 1, 1) + yield status + + # Main content area - dynamically populated based on workflow stage + yield Vertical(id="content_area") + + # Checklist area - always visible + yield Vertical(id="checklist_area") + + yield Footer() + + def on_mount(self) -> None: + """Initialize the screen when mounted.""" + self._update_checklist() + self._show_source_policy_selection() + + def watch_workflow_stage(self, old_value: str, new_value: str) -> None: + """React to workflow stage changes.""" + logger.debug(f"Workflow stage changed from {old_value} to {new_value}") + self._update_status_message() + self._update_checklist() + + def _update_status_message(self) -> None: + """Update the status message based on current workflow stage.""" + status_widget = self.query_one("#workflow_status", Static) + + stage_messages = { + "select_source": "Step 1: Select Source Policies", + "select_destination": "Step 2: Select Destination Policy", + "select_allowlist": "Step 3: Select Destination Allowlist", + "fetch_data": "Step 4: Fetch Execution History", + "fetching": "Fetching and sorting execution data...", + "first_review": "Step 5: First Manual Review", + "build_paths": "Step 6: Building Path Exclusions", + "second_review": "Step 7: Second Manual Review", + "test": "Step 8: Test - Preview Changes", + "liftoff": "Step 9: Liftoff - Apply Changes", + "complete": "Workflow Complete", + } + + status_widget.update(stage_messages.get(self.workflow_stage, "Unknown Stage")) + + def _update_checklist(self) -> None: + """Update the preparation checklist display.""" + checklist = self.query_one("#checklist_area", Vertical) + checklist.remove_children() + + # Checklist container with border + checklist_container = Vertical() + checklist_container.styles.border = ("round", "blue") + checklist_container.styles.margin = (1, 2) + checklist_container.styles.padding = 1 + + # Mount the container to the checklist area FIRST + checklist.mount(checklist_container) + + # NOW mount children to the container + checklist_title = Static("Preparation Checklist") + checklist_title.styles.text_style = "bold" + checklist_container.mount(checklist_title) + + # Step 1: Source Policies + step1_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" + step1_text = f"{step1_status} Source Policies: " + if self.source_policies: + step1_text += ", ".join([p.name for p in self.source_policies[:3]]) + if len(self.source_policies) > 3: + step1_text += f" (+{len(self.source_policies)-3} more)" + else: + step1_text += "Not selected" + step1 = Static(step1_text) + if self.source_policies: + step1.styles.color = "green" + else: + step1.styles.text_style = "dim" + checklist_container.mount(step1) + + # Step 2: Destination Policy + step2_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" + step2_text = f"{step2_status} Destination Policy: " + step2_text += ( + self.destination_policy.name if self.destination_policy else "Not selected" + ) + step2 = Static(step2_text) + if self.destination_policy: + step2.styles.color = "green" + else: + step2.styles.text_style = "dim" + checklist_container.mount(step2) + + # Step 3: Destination Allowlist + step3_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" + step3_text = f"{step3_status} Allowlist: " + step3_text += ( + self.destination_allowlist.name + if self.destination_allowlist + else "Not selected" + ) + step3 = Static(step3_text) + if self.destination_allowlist: + step3.styles.color = "green" + else: + step3.styles.text_style = "dim" + checklist_container.mount(step3) + + # Step 4: Data Fetched + data_fetched = self.approved_df is not None or self.needs_review_df is not None + step4_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" + step4_text = f"{step4_status} Data Fetched: " + if data_fetched: + total = 0 + if self.approved_df is not None: + total += len(self.approved_df) + if self.needs_review_df is not None: + total += len(self.needs_review_df) + step4_text += f"{total} executions" + else: + step4_text += "Not fetched" + step4 = Static(step4_text) + if data_fetched: + step4.styles.color = "green" + else: + step4.styles.text_style = "dim" + checklist_container.mount(step4) + + # Step 5: First Review Complete + first_review_path = os.path.join(self.working_dir, "Approved") + if self.source_policies: + approved_file = os.path.join( + first_review_path, + f"{self.source_policies[0].name}_approved_executions.csv", + ) + first_review_done = os.path.exists(approved_file) + else: + first_review_done = False + + step5_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" + step5_text = f"{step5_status} First Review: " + step5_text += "Complete" if first_review_done else "Pending" + step5 = Static(step5_text) + if first_review_done: + step5.styles.color = "green" + else: + step5.styles.text_style = "dim" + checklist_container.mount(step5) + + # Step 6: Paths Generated + paths_generated = self.primary_paths_df is not None + step6_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" + step6_text = f"{step6_status} Paths Generated: " + if paths_generated: + step6_text += f"{len(self.primary_paths_df)} primary paths" + else: + step6_text += "Not generated" + step6 = Static(step6_text) + if paths_generated: + step6.styles.color = "green" + else: + step6.styles.text_style = "dim" + checklist_container.mount(step6) + + def _show_source_policy_selection(self) -> None: + """Show the source policy selection screen.""" + self.workflow_stage = "select_source" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + instruction = Static("Select source policies (click rows to toggle selection):") + instruction.styles.margin = (0, 1, 0, 1) + content.mount(instruction) + + # Create a DataTable for multi-select + table = DataTable(id="source_policy_table") + table.styles.height = "30vh" + table.styles.overflow_y = "auto" + table.cursor_type = "row" + table.zebra_stripes = True + + # Add columns - checkbox first, then data columns + table.add_columns("☐", "Name", "ID", "Parent") + + # Add rows + for policy in self.policies: + # Skip parent policies + if policy.parent == "global-policy-settings": + continue + checkbox = "☐" # All start unchecked + table.add_row( + checkbox, + policy.name, + str(policy.groupid), + policy.parent or "N/A", + key=str(policy.groupid), + ) + + content.mount(table) + + # Control buttons + control_container = Horizontal() + control_container.styles.height = "auto" + control_container.styles.margin = (0, 1) + + # Mount the container first + content.mount(control_container) + + # Then add buttons to it + + select_none_btn = Button("Clear Selection", id="select_none_source") + select_none_btn.styles.width = "1fr" + select_none_btn.styles.margin = (0, 1, 0, 0) + + continue_btn = Button( + "β†’ Continue", id="continue_source_selection", variant="primary" + ) + continue_btn.styles.width = "1fr" + continue_btn.styles.margin = (0, 0, 0, 1) + + control_container.mount(select_none_btn) + control_container.mount(continue_btn) + + # Track selected policies + if not hasattr(self, "selected_source_policy_ids"): + self.selected_source_policy_ids = set() + else: + self.selected_source_policy_ids.clear() + + def _show_destination_policy_selection(self) -> None: + """Show the destination policy selection screen.""" + self.workflow_stage = "select_destination" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + instruction = Static( + f"Selected Source: {', '.join([p.name for p in self.source_policies])}\n\n" + "Select the destination policy for enforcement:" + ) + instruction.styles.margin = (0, 1, 1, 1) + content.mount(instruction) + + # Create policy selector with single-select + policy_selector = PolicySelector(self.policies) + content.mount(policy_selector) + + def _show_allowlist_selection(self) -> None: + """Show the allowlist selection screen.""" + self.workflow_stage = "select_allowlist" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + instruction = Static( + f"Destination Policy: {self.destination_policy.name}\n\n" + "Select the allowlist to use:" + ) + instruction.styles.margin = (0, 1, 1, 1) + content.mount(instruction) + + # Fetch allowlists for the destination policy + try: + allowlists_df = self.api.policy_list_allowlists( + self.destination_policy.groupid + ) + allowlists = [ + Allowlist(**row.to_dict()) for _, row in allowlists_df.iterrows() + ] + + if not allowlists: + content.mount( + Static("No allowlists found for this policy!", id="no_allowlists") + ) + return + + # Add instruction + instruction = Static("Click a row to select the allowlist for this policy:") + instruction.styles.margin = (0, 1, 1, 1) + content.mount(instruction) + + # Create table for allowlist selection + table = DataTable(id="allowlist_table") + table.styles.height = "auto" + table.styles.max_height = "50%" + table.cursor_type = "row" + + table.add_columns("ID", "Name", "Version") + for al in allowlists: + table.add_row(str(al.applicationid), al.name, str(al.version)) + + content.mount(table) + + # Store allowlists for reference + self.allowlists = allowlists + + except Exception as e: + logger.error(f"Failed to fetch allowlists: {e}") + content.mount(Static(f"Error fetching allowlists: {str(e)}")) + + def _show_fetch_data(self) -> None: + """Show the data fetching options screen.""" + self.workflow_stage = "fetch_data" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + instruction = Static( + f"Ready to fetch execution history from: {', '.join([p.name for p in self.source_policies])}\n\n" + "Enter the number of days of history to fetch:" + ) + instruction.styles.margin = (0, 1, 1, 1) + content.mount(instruction) + + # Days input + days_container = Horizontal() + days_container.styles.margin = (1, 1) + content.mount(days_container) + + days_label = Static("History Days (1-150): ") + days_input = Input(value="150", id="history_days_input", type="integer") + days_input.styles.width = 20 + + days_container.mount(days_label) + days_container.mount(days_input) + + # Type selection + type_instruction = Static("\nSelect execution types to include:") + type_instruction.styles.margin = (1, 1, 0, 1) + content.mount(type_instruction) + + type_info = Static( + "Default: Types 1, 2, 6, 7 (Standard executions)\n" + "You can customize this if needed." + ) + type_info.styles.margin = (0, 1, 1, 1) + type_info.styles.text_style = "dim" + content.mount(type_info) + + # Fetch button + button_container = Horizontal() + button_container.styles.margin = (2, 1) + content.mount(button_container) + + fetch_btn = Button("Fetch Data", id="fetch_data_btn", variant="primary") + fetch_btn.styles.margin = (0, 1, 0, 0) + + skip_btn = Button("Skip (Use Existing)", id="skip_fetch_btn") + + button_container.mount(fetch_btn) + button_container.mount(skip_btn) + + def _fetch_execution_data(self, history_days: int) -> None: + """Fetch and sort execution data.""" + self.workflow_stage = "fetching" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + status = Static("Fetching execution history...\nThis may take a few moments...") + status.styles.margin = (2, 1) + content.mount(status) + + # Perform fetch in background + self.call_later(lambda: self._perform_fetch(history_days)) + + def _perform_fetch(self, history_days: int) -> None: + """Perform the actual data fetching.""" + try: + # Fetch execution history + policy_executions = ExecutionHistoryRecord.from_policies( + self.api, + self.source_policies, + type_=[1, 2, 6, 7], + history_days=history_days, + ) + + # Enrich with hash data + enriched_executions = ExecutionHistoryRecord.enrich_with_hashes( + self.api, policy_executions + ) + + # Categorize by hash decision + categorized_executions = ( + ExecutionHistoryRecord.categorize_executions_by_hash_decision( + enriched_executions + ) + ) + + # Sort by decision + approved, unapproved, needs_review, unknown = ( + ExecutionHistoryRecord.sort_by_hash_decision(categorized_executions) + ) + + # Store the data + self.approved_df = ( + pd.DataFrame([r.__dict__ for r in approved]) + if approved + else pd.DataFrame() + ) + self.unapproved_df = ( + pd.DataFrame([r.__dict__ for r in unapproved]) + if unapproved + else pd.DataFrame() + ) + self.needs_review_df = ( + pd.DataFrame([r.__dict__ for r in needs_review]) + if needs_review + else pd.DataFrame() + ) + + # Save to files + self._save_fetched_data() + + # Show results + self._show_fetch_results() + + except Exception as e: + logger.error(f"Failed to fetch execution data: {e}", exc_info=True) + self.app.notify(f"Failed to fetch data: {str(e)}", severity="error") + self._show_fetch_data() + + def _save_fetched_data(self) -> None: + """Save fetched data to CSV and HTML files.""" + if not self.source_policies: + return + + policy_name = self.source_policies[0].name + review_dir = os.path.join(self.working_dir, "Needs_Review", "Review_First") + html_dir = os.path.join(self.working_dir, "Needs_Review", "HTML") + + os.makedirs(review_dir, exist_ok=True) + os.makedirs(html_dir, exist_ok=True) + + # Save each category + categories = { + "approved": self.approved_df, + "needs_review": self.needs_review_df, + "unapproved": self.unapproved_df, + } + + for label, df in categories.items(): + if df is not None and not df.empty: + csv_path = os.path.join( + review_dir, f"{policy_name}_{label}_executions.csv" + ) + html_path = os.path.join(html_dir, f"{policy_name}_{label}.html") + + df.to_csv(csv_path, index=False) + formatHTML(df, html_path) + + logger.info(f"Saved {label} executions to {csv_path}") + + def _show_fetch_results(self) -> None: + """Show the results of data fetching.""" + self.workflow_stage = "first_review" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Results summary + approved_count = len(self.approved_df) if self.approved_df is not None else 0 + review_count = ( + len(self.needs_review_df) if self.needs_review_df is not None else 0 + ) + unapproved_count = ( + len(self.unapproved_df) if self.unapproved_df is not None else 0 + ) + + summary = Static( + f"Data Fetch Complete!\n\n" + f"Approved: {approved_count} executions\n" + f"Needs Review: {review_count} executions\n" + f"Unapproved: {unapproved_count} executions (automatically excluded)\n" + ) + summary.styles.margin = (1, 1) + content.mount(summary) + + # Tab selection for review + tab_container = Horizontal() + tab_container.styles.margin = (1, 1) + content.mount(tab_container) + + approved_tab_btn = Button( + "Review Approved", id="show_approved_tab", variant="primary" + ) + approved_tab_btn.styles.margin = (0, 1, 0, 0) + + review_tab_btn = Button("Review Needs Review", id="show_needs_review_tab") + + tab_container.mount(approved_tab_btn) + tab_container.mount(review_tab_btn) + + # Show approved table by default + self._show_review_table("approved") + + def _show_review_table(self, table_type: str) -> None: + """Show an editable DataTable for reviewing executions.""" + content = self.query_one("#content_area", Vertical) + + # Remove existing table if any + existing_table = content.query("DataTable") + for table in existing_table: + table.remove() + existing_controls = content.query("#review_controls") + for control in existing_controls: + control.remove() + + # Determine which dataframe to show + if table_type == "approved": + df = self.approved_df + table_id = "approved_review_table" + title = "Approved Executions - Select rows to REMOVE:" + else: + df = self.needs_review_df + table_id = "needs_review_table" + title = "Needs Review Executions - Select rows to REMOVE:" + + if df is None or df.empty: + empty_msg = Static(f"No {table_type} executions to review") + empty_msg.styles.margin = (2, 1) + content.mount(empty_msg) + return + + # Instructions + instruction = Static(title) + instruction.styles.margin = (1, 1) + instruction.styles.text_style = "bold" + content.mount(instruction) + + # Help text + help_text = Static( + "Use arrows to navigate, SPACE to select/deselect rows, 'd' to delete selected rows\n" + "Selected rows will be highlighted and removed from the approved list" + ) + help_text.styles.margin = (0, 1, 1, 1) + help_text.styles.text_style = "dim" + content.mount(help_text) + + # Create the review table + review_table = DataTable(id=table_id) + review_table.styles.height = "50vh" + review_table.cursor_type = "row" + review_table.zebra_stripes = True + + # Add columns - checkbox first, then important fields + important_cols = [ + "filename", + "publisher", + "sha256", + "filepath", + "hostname", + "datetime", + ] + available_cols = [col for col in important_cols if col in df.columns] + + if available_cols: + # Add checkbox column first + review_table.add_columns("☐", *available_cols) + + # Add rows with row keys for tracking + for idx, row in df.iterrows(): + checkbox = "☐" # All start unchecked + row_data = [str(row.get(col, "")) for col in available_cols] + review_table.add_row(checkbox, *row_data, key=str(idx)) + + content.mount(review_table) + + # Control buttons + control_container = Horizontal(id="review_controls") + control_container.styles.margin = (1, 1) + content.mount(control_container) + + select_all_btn = Button("Select All", id="select_all_rows") + select_all_btn.styles.margin = (0, 1, 0, 0) + + select_none_btn = Button("Clear Selection", id="select_none_rows") + select_none_btn.styles.margin = (0, 1, 0, 0) + + delete_btn = Button( + "Delete Selected", id="delete_selected_rows", variant="error" + ) + delete_btn.styles.margin = (0, 1, 0, 0) + + row_count = Static(f"Total rows: {len(df)}") + row_count.styles.margin = (0, 1, 0, 2) + + control_container.mount(select_all_btn) + control_container.mount(select_none_btn) + control_container.mount(delete_btn) + control_container.mount(row_count) + + # Continue button (always at bottom) + if not content.query("#review_continue_container"): + continue_container = Horizontal(id="review_continue_container") + continue_container.styles.margin = (2, 1, 0, 1) + continue_container.styles.dock = "bottom" + + # Mount the container to the content area FIRST + content.mount(continue_container) + + # NOW mount buttons into the container + export_btn = Button("Export to CSV", id="export_review") + export_btn.styles.margin = (0, 1, 0, 0) + + continue_btn = Button( + "β†’ Finish Review & Continue", + id="continue_from_review", + variant="success", + ) + + continue_container.mount(export_btn) + continue_container.mount(continue_btn) + + # Track selected rows + if not hasattr(self, "selected_rows"): + self.selected_rows = set() + else: + self.selected_rows.clear() + + # Store current review type + self.current_review_type = table_type + + def _delete_selected_rows(self) -> None: + """Delete selected rows from the current dataframe.""" + if not hasattr(self, "selected_rows") or not self.selected_rows: + self.app.notify("No rows selected for deletion", severity="warning") + return + + # Determine which dataframe to modify + if self.current_review_type == "approved": + df = self.approved_df + else: + df = self.needs_review_df + + if df is None: + return + + # Get indices to keep (not in selected rows) + indices_to_delete = [int(idx) for idx in self.selected_rows] + df_filtered = df.drop(index=indices_to_delete, errors="ignore") + + # Update the dataframe + if self.current_review_type == "approved": + self.approved_df = df_filtered + else: + self.needs_review_df = df_filtered + + # Clear selection + self.selected_rows.clear() + + # Refresh the table + self._show_review_table(self.current_review_type) + + self.app.notify( + f"Deleted {len(indices_to_delete)} rows", severity="information" + ) + + def _build_paths_and_publishers(self) -> None: + """Build path exclusions and publisher lists.""" + self.workflow_stage = "build_paths" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + status = Static("Building path exclusions and publisher lists...") + status.styles.margin = (2, 1) + content.mount(status) + + self.call_later(self._perform_path_build) + + def _perform_path_build(self) -> None: + """Perform the actual path and publisher building.""" + try: + if not self.source_policies: + raise ValueError("No source policies selected") + + policy_name = self.source_policies[0].name + approved_path = os.path.join( + self.working_dir, "Approved", f"{policy_name}_approved_executions.csv" + ) + review_path = os.path.join( + self.working_dir, + "Approved", + f"{policy_name}_needs_review_executions.csv", + ) + + # Load approved files + df1 = ( + pd.read_csv(approved_path) + if os.path.exists(approved_path) + else pd.DataFrame() + ) + df2 = ( + pd.read_csv(review_path) + if os.path.exists(review_path) + else pd.DataFrame() + ) + + if df1.empty and df2.empty: + self.app.notify( + "No approved files found! Please complete first review.", + severity="error", + ) + self._show_fetch_results() + return + + # Combine dataframes + all_approved = pd.concat([df1, df2], ignore_index=True) + if "filename" in all_approved.columns: + all_approved = all_approved.sort_values(by="filename") + + # Calculate paths + path_exclusion_const = get_system_value( + "PATH_EXCLUSION_CONST", cast_type=int + ) + if path_exclusion_const: + # Primary paths + self.primary_paths_df = self._calculate_paths( + all_approved, path_exclusion_const + ) + + # Secondary paths + remaining = all_approved[ + ~all_approved["sha256"].isin(self.primary_paths_df["sha256"]) + ] + self.secondary_paths_df = self._calculate_paths( + remaining, path_exclusion_const - 1 + ) + + # Remaining hashes + self.remaining_hashes_df = remaining[ + ~remaining["sha256"].isin(self.secondary_paths_df["sha256"]) + ] + + # Extract publishers + if not all_approved.empty: + publist = all_approved[ + all_approved["publisher"] != "Not Signed" + ].drop_duplicates(subset=["publisher"]) + + # Remove bad publishers + bad_publishers = get_system_list("BAD_PUBLISHERS") + if bad_publishers: + pattern = "|".join(bad_publishers) + publist = publist[ + ~publist["publisher"].str.contains( + pattern, case=False, na=False, regex=True + ) + ] + + self.publishers_df = publist + + # Save to Review_Second folder + self._save_path_data() + + # Show results + self._show_path_results() + + except Exception as e: + logger.error(f"Failed to build paths: {e}", exc_info=True) + self.app.notify(f"Failed to build paths: {str(e)}", severity="error") + self._show_fetch_results() + + def _calculate_paths(self, df: pd.DataFrame, depth: int) -> pd.DataFrame: + """Calculate path exclusions at specified depth.""" + # Simplified path calculation - in production this would be more complex + if df.empty or "filepath" not in df.columns: + return pd.DataFrame() + + paths = [] + for filepath in df["filepath"].unique(): + if pd.isna(filepath): + continue + parts = filepath.split(self.path_split) + if len(parts) > depth: + truncated = self.path_split.join(parts[:depth]) + paths.append(truncated) + + # Create dataframe with unique paths + if paths: + path_df = pd.DataFrame({"longestcfp": list(set(paths))}) + # Add mock sha256 for compatibility + path_df["sha256"] = path_df.index.astype(str) + return path_df + return pd.DataFrame() + + def _save_path_data(self) -> None: + """Save path and publisher data to files.""" + if not self.source_policies: + return + + policy_name = self.source_policies[0].name + review_dir = os.path.join(self.working_dir, "Needs_Review", "Review_Second") + html_dir = os.path.join(self.working_dir, "Needs_Review", "HTML") + + os.makedirs(review_dir, exist_ok=True) + os.makedirs(html_dir, exist_ok=True) + + # Save each dataframe + dataframes = { + "primary_paths": self.primary_paths_df, + "secondary_paths": self.secondary_paths_df, + "publishers": self.publishers_df, + "remaining_hashes": self.remaining_hashes_df, + } + + for name, df in dataframes.items(): + if df is not None and not df.empty: + csv_path = os.path.join(review_dir, f"{policy_name}_{name}.csv") + html_path = os.path.join(html_dir, f"{policy_name}_{name}.html") + + df.to_csv(csv_path, index=False) + formatHTML(df, html_path) + + logger.info(f"Saved {name} to {csv_path}") + + def _show_path_results(self) -> None: + """Show the results of path building.""" + self.workflow_stage = "second_review" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Results summary + primary_count = ( + len(self.primary_paths_df) if self.primary_paths_df is not None else 0 + ) + secondary_count = ( + len(self.secondary_paths_df) if self.secondary_paths_df is not None else 0 + ) + publishers_count = ( + len(self.publishers_df) if self.publishers_df is not None else 0 + ) + + summary = Static( + f"Path Analysis Complete!\n\n" + f"Primary Paths: {primary_count}\n" + f"Secondary Paths: {secondary_count}\n" + f"Publishers: {publishers_count}" + ) + summary.styles.margin = (1, 1) + content.mount(summary) + + # Tab selection for different review types + tab_container = Horizontal() + tab_container.styles.margin = (1, 1) + content.mount(tab_container) + + paths_tab_btn = Button("Review Paths", id="show_paths_tab", variant="primary") + paths_tab_btn.styles.margin = (0, 1, 0, 0) + + publishers_tab_btn = Button("Review Publishers", id="show_publishers_tab") + publishers_tab_btn.styles.margin = (0, 1, 0, 0) + + remaining_tab_btn = Button("Remaining Hashes", id="show_remaining_tab") + + tab_container.mount(paths_tab_btn) + tab_container.mount(publishers_tab_btn) + tab_container.mount(remaining_tab_btn) + + # Show paths table by default + self._show_path_review_table("paths") + + def _show_path_review_table(self, table_type: str) -> None: + """Show an editable DataTable for reviewing paths/publishers.""" + content = self.query_one("#content_area", Vertical) + + # Remove existing table if any + existing_table = content.query("DataTable") + for table in existing_table: + table.remove() + existing_controls = content.query("#path_review_controls") + for control in existing_controls: + control.remove() + + # Determine which dataframe to show + if table_type == "paths": + # Combine primary and secondary paths for review + dfs = [] + if self.primary_paths_df is not None and not self.primary_paths_df.empty: + df_copy = self.primary_paths_df.copy() + df_copy["type"] = "primary" + dfs.append(df_copy) + if ( + self.secondary_paths_df is not None + and not self.secondary_paths_df.empty + ): + df_copy = self.secondary_paths_df.copy() + df_copy["type"] = "secondary" + dfs.append(df_copy) + + if dfs: + df = pd.concat(dfs, ignore_index=True) + else: + df = pd.DataFrame() + + table_id = "paths_review_table" + title = "Path Exclusions - Select paths to REMOVE:" + columns = ["longestcfp", "type"] if not df.empty else [] + + elif table_type == "publishers": + df = self.publishers_df + table_id = "publishers_review_table" + title = "Approved Publishers - Select publishers to REMOVE:" + columns = ["publisher"] if df is not None and not df.empty else [] + + else: # remaining + df = self.remaining_hashes_df + table_id = "remaining_review_table" + title = "Remaining Hashes (not covered by paths) - For reference only:" + columns = ( + ["filename", "filepath", "sha256"] + if df is not None and not df.empty + else [] + ) + + if df is None or df.empty: + empty_msg = Static(f"No {table_type} to review") + empty_msg.styles.margin = (2, 1) + content.mount(empty_msg) + return + + # Instructions + instruction = Static(title) + instruction.styles.margin = (1, 1) + instruction.styles.text_style = "bold" + content.mount(instruction) + + # Help text (different for remaining hashes) + if table_type != "remaining": + help_text = Static( + "Use arrows to navigate, SPACE to select/deselect rows, 'd' to delete selected rows\n" + "Selected items will be removed from the final approval list" + ) + else: + help_text = Static( + "These hashes cannot be approved via path exclusions.\n" + "They will need individual hash approval if required." + ) + help_text.styles.margin = (0, 1, 1, 1) + help_text.styles.text_style = "dim" + content.mount(help_text) + + # Create the review table + review_table = DataTable(id=table_id) + review_table.styles.height = "40vh" + review_table.cursor_type = "row" + review_table.zebra_stripes = True + + # Add columns - checkbox first, then data columns + if columns: + # For DataFrames, also check what columns actually exist + available_cols = [col for col in columns if col in df.columns] + if available_cols: + # Add checkbox column first + review_table.add_columns("☐", *available_cols) + + # Add rows with row keys for tracking + for idx, row in df.iterrows(): + checkbox = "☐" # All start unchecked + row_data = [str(row.get(col, "")) for col in available_cols] + review_table.add_row(checkbox, *row_data, key=str(idx)) + + content.mount(review_table) + + # Control buttons (not for remaining hashes view) + if table_type != "remaining": + control_container = Horizontal(id="path_review_controls") + control_container.styles.margin = (1, 1) + content.mount(control_container) + + select_all_btn = Button("Select All", id="select_all_path_rows") + select_all_btn.styles.margin = (0, 1, 0, 0) + + select_none_btn = Button("Clear Selection", id="select_none_path_rows") + select_none_btn.styles.margin = (0, 1, 0, 0) + + delete_btn = Button( + "Delete Selected", id="delete_selected_path_rows", variant="error" + ) + delete_btn.styles.margin = (0, 1, 0, 0) + + row_count = Static(f"Total items: {len(df)}") + row_count.styles.margin = (0, 1, 0, 2) + + control_container.mount(select_all_btn) + control_container.mount(select_none_btn) + control_container.mount(delete_btn) + control_container.mount(row_count) + + # Continue button (always at bottom) + if not content.query("#path_continue_container"): + continue_container = Horizontal(id="path_continue_container") + continue_container.styles.margin = (2, 1, 0, 1) + continue_container.styles.dock = "bottom" + + export_btn = Button("Export to CSV", id="export_path_review") + export_btn.styles.margin = (0, 1, 0, 0) + + continue_btn = Button( + "Build Preflight", id="build_preflight", variant="success" + ) + + continue_container.mount(export_btn) + continue_container.mount(continue_btn) + content.mount(continue_container) + + # Track selected rows + if not hasattr(self, "selected_path_rows"): + self.selected_path_rows = set() + else: + self.selected_path_rows.clear() + + # Store current review type + self.current_path_review_type = table_type + + def _delete_selected_path_rows(self) -> None: + """Delete selected rows from the current path/publisher dataframe.""" + if not hasattr(self, "selected_path_rows") or not self.selected_path_rows: + self.app.notify("No rows selected for deletion", severity="warning") + return + + indices_to_delete = [int(idx) for idx in self.selected_path_rows] + + # Determine which dataframe to modify + if self.current_path_review_type == "paths": + # Need to handle primary and secondary paths + # For simplicity, rebuild both dataframes + # This is a simplified approach - in production you'd track which type each row belongs to + if self.primary_paths_df is not None: + self.primary_paths_df = self.primary_paths_df.drop( + index=[ + i for i in indices_to_delete if i < len(self.primary_paths_df) + ], + errors="ignore", + ) + if self.secondary_paths_df is not None: + offset = ( + len(self.primary_paths_df) + if self.primary_paths_df is not None + else 0 + ) + self.secondary_paths_df = self.secondary_paths_df.drop( + index=[i - offset for i in indices_to_delete if i >= offset], + errors="ignore", + ) + + elif self.current_path_review_type == "publishers": + if self.publishers_df is not None: + self.publishers_df = self.publishers_df.drop( + index=indices_to_delete, errors="ignore" + ) + + # Clear selection + self.selected_path_rows.clear() + + # Refresh the table + self._show_path_review_table(self.current_path_review_type) + + self.app.notify( + f"Deleted {len(indices_to_delete)} items", severity="information" + ) + + def _build_preflight(self) -> None: + """Build preflight files for testing.""" + try: + # This would contain the logic to build the final preflight files + # For now, we'll just show the test screen + self._show_test_screen() + except Exception as e: + logger.error(f"Failed to build preflight: {e}") + self.app.notify(f"Failed to build preflight: {str(e)}", severity="error") + + def _show_test_screen(self) -> None: + """Show the test/preview screen.""" + self.workflow_stage = "test" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + summary = Static( + "Test Mode - Preview Changes\n\n" "The following changes will be applied:\n" + ) + summary.styles.margin = (1, 1) + content.mount(summary) + + # Show what would be changed + changes_text = "" + + if self.destination_policy: + changes_text += f"Policy: {self.destination_policy.name}\n" + + if self.primary_paths_df is not None and not self.primary_paths_df.empty: + changes_text += f"Add {len(self.primary_paths_df)} path exclusions\n" + + if self.publishers_df is not None and not self.publishers_df.empty: + changes_text += f"Add {len(self.publishers_df)} approved publishers\n" + + if self.destination_allowlist: + changes_text += f"\nAllowlist: {self.destination_allowlist.name}\n" + + if self.approved_df is not None and not self.approved_df.empty: + changes_text += f"Add {len(self.approved_df)} approved hashes\n" + + changes = Static(changes_text) + changes.styles.margin = (0, 2) + changes.styles.border = ("round", "cyan") + changes.styles.padding = 1 + content.mount(changes) + + # Warning + warning = Static( + " Òő ï¸Warning: These changes cannot be easily undone.Òő ï¸\n" + "Please review carefully before proceeding." + ) + warning.styles.margin = (1, 1) + warning.styles.color = "yellow" + content.mount(warning) + + # Buttons + button_container = Horizontal() + button_container.styles.margin = (2, 1) + content.mount(button_container) + + liftoff_btn = Button("Liftoff - Apply Changes", id="liftoff", variant="success") + + button_container.mount(liftoff_btn) + + def _apply_changes(self) -> None: + """Apply the changes to policies and allowlists.""" + self.workflow_stage = "liftoff" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + status = Static("Applying changes...\nPlease wait...") + status.styles.margin = (2, 1) + content.mount(status) + + self.call_later(self._perform_apply) + + def _perform_apply(self) -> None: + """Perform the actual application of changes.""" + try: + results = [] + + # Apply path exclusions to policy + if self.destination_policy and self.primary_paths_df is not None: + # This would call the actual API methods + results.append("Applied path exclusions to policy") + + # Apply publishers to policy + if self.destination_policy and self.publishers_df is not None: + # This would call the actual API methods + results.append("Applied approved publishers to policy") + + # Apply hashes to allowlist + if self.destination_allowlist and self.approved_df is not None: + # This would call the actual API methods + results.append("Applied approved hashes to allowlist") + + self._show_completion(results) + + except Exception as e: + logger.error(f"Failed to apply changes: {e}", exc_info=True) + self.app.notify(f"Failed to apply changes: {str(e)}", severity="error") + self._show_test_screen() + + def _show_completion(self, results: List[str]) -> None: + """Show completion screen.""" + self.workflow_stage = "complete" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + summary = Static( + "Policy Preparation Complete!\n\n" + "The following changes have been applied:" + ) + summary.styles.margin = (1, 1) + content.mount(summary) + + for result in results: + result_widget = Static(f" {result}") + result_widget.styles.margin = (0, 2) + content.mount(result_widget) + + # Final message + final = Static( + f"\nPolicy '{self.destination_policy.name}' is now ready for enforcement!" + ) + final.styles.margin = (2, 1) + final.styles.color = "green" + content.mount(final) + + # Done button + done_btn = Button("Done", id="workflow_done") + done_btn.styles.margin = (2, 0, 0, 0) + done_btn.styles.width = "50%" + content.mount(done_btn) + + # Event handlers + def on_policy_selector_policy_selected( + self, message: PolicySelector.PolicySelected + ) -> None: + """Handle policy selection from PolicySelector widget.""" + if self.workflow_stage == "select_destination": + self.destination_policy = message.policy + logger.info(f"Selected destination policy: {self.destination_policy.name}") + self._show_allowlist_selection() + + def _refresh_table_checkboxes(self, table_id: str, selected_keys: set) -> None: + """Refresh checkbox column in a table based on selected keys.""" + try: + table = self.query_one(f"#{table_id}", DataTable) + + # Update checkboxes in place without rebuilding the table + row_index = 0 + for row_key in table.rows.keys(): + # Determine if this row should be checked + checkbox = "β˜‘οΈ" if str(row_key) in selected_keys else "☐" + + # Update the checkbox cell (first column, index 0) + try: + table.update_cell_at((row_index, 0), checkbox) + except Exception as e: + logger.debug(f"Could not update cell at row {row_index}: {e}") + + row_index += 1 + + except Exception as e: + logger.debug(f"Error refreshing table {table_id}: {e}") + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle row selection in data tables.""" + table = event.data_table + + # Handle source policy selection + if table.id == "source_policy_table": + # Use cursor_row for reliable row index + row_index = table.cursor_row + # Get the row key from the table + row_keys = list(table.rows.keys()) + if row_index < len(row_keys): + row_key = str(row_keys[row_index]) + if row_key in self.selected_source_policy_ids: + self.selected_source_policy_ids.remove(row_key) + else: + self.selected_source_policy_ids.add(row_key) + # Refresh checkbox display + self._refresh_table_checkboxes( + table.id, self.selected_source_policy_ids + ) + self.app.notify( + f"Selected {len(self.selected_source_policy_ids)} policies", + timeout=1, + ) + + # Handle allowlist selection + elif table.id == "allowlist_table": + # Get selected allowlist + row_index = table.cursor_row + if hasattr(self, "allowlists") and row_index < len(self.allowlists): + self.destination_allowlist = self.allowlists[row_index] + logger.info(f"Selected allowlist: {self.destination_allowlist.name}") + self._show_fetch_data() + + # Handle review table selections (toggle selection) + elif table.id in ["approved_review_table", "needs_review_table"]: + # Use cursor_row for reliable row index + row_index = table.cursor_row + # Get the row key from the table + row_keys = list(table.rows.keys()) + if row_index < len(row_keys): + row_key = str(row_keys[row_index]) + if row_key in self.selected_rows: + self.selected_rows.remove(row_key) + else: + self.selected_rows.add(row_key) + # Refresh checkbox display + self._refresh_table_checkboxes(table.id, self.selected_rows) + self.app.notify(f"Selected {len(self.selected_rows)} rows", timeout=1) + + # Handle path/publisher review selections + elif table.id in ["paths_review_table", "publishers_review_table"]: + # Use cursor_row for reliable row index + row_index = table.cursor_row + # Get the row key from the table + row_keys = list(table.rows.keys()) + if row_index < len(row_keys): + row_key = str(row_keys[row_index]) + if row_key in self.selected_path_rows: + self.selected_path_rows.remove(row_key) + else: + self.selected_path_rows.add(row_key) + # Refresh checkbox display + self._refresh_table_checkboxes(table.id, self.selected_path_rows) + self.app.notify( + f"Selected {len(self.selected_path_rows)} items", timeout=1 + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + button_id = event.button.id + + # Source policy selection buttons + if button_id == "select_none_source": + self.selected_source_policy_ids.clear() + # Refresh checkbox display + self._refresh_table_checkboxes( + "source_policy_table", self.selected_source_policy_ids + ) + self.app.notify("Cleared selection", timeout=1) + + elif button_id == "continue_source_selection": + if not self.selected_source_policy_ids: + self.app.notify( + "Please select at least one source policy", severity="warning" + ) + else: + # Get the actual policy objects + self.source_policies = [ + p + for p in self.policies + if str(p.groupid) in self.selected_source_policy_ids + ] + logger.info( + f"Selected source policies: {[p.name for p in self.source_policies]}" + ) + self._show_destination_policy_selection() + + # Tab switching buttons + elif button_id == "show_approved_tab": + self._show_review_table("approved") + + elif button_id == "show_needs_review_tab": + self._show_review_table("needs_review") + + elif button_id == "show_paths_tab": + self._show_path_review_table("paths") + + elif button_id == "show_publishers_tab": + self._show_path_review_table("publishers") + + elif button_id == "show_remaining_tab": + self._show_path_review_table("remaining") + + # Row selection buttons + elif button_id == "select_all_rows": + self._select_all_rows() + + elif button_id == "select_none_rows": + self._select_none_rows() + + elif button_id == "delete_selected_rows": + self._delete_selected_rows() + + elif button_id == "select_all_path_rows": + self._select_all_path_rows() + + elif button_id == "select_none_path_rows": + self._select_none_path_rows() + + elif button_id == "delete_selected_path_rows": + self._delete_selected_path_rows() + + # Export buttons + elif button_id == "export_review": + self._export_review_data() + + elif button_id == "export_path_review": + self._export_path_review_data() + + # Original button handlers + elif button_id == "fetch_data_btn": + # Get history days from input + try: + days_input = self.query_one("#history_days_input", Input) + history_days = int(days_input.value) + if 1 <= history_days <= 150: + self.history_days = history_days + self._fetch_execution_data(history_days) + else: + self.app.notify( + "Please enter a value between 1 and 150", severity="warning" + ) + except (ValueError, TypeError): + self.app.notify("Please enter a valid number", severity="warning") + + elif button_id == "skip_fetch_btn": + # Check if data already exists + if self.source_policies: + policy_name = self.source_policies[0].name + approved_path = os.path.join( + self.working_dir, + "Needs_Review", + "Review_First", + f"{policy_name}_approved_executions.csv", + ) + if os.path.exists(approved_path): + # Load existing data + self.approved_df = pd.read_csv(approved_path) + review_path = approved_path.replace("approved", "needs_review") + if os.path.exists(review_path): + self.needs_review_df = pd.read_csv(review_path) + self._show_fetch_results() + else: + self.app.notify( + "No existing data found. Please fetch new data.", + severity="warning", + ) + + elif button_id == "continue_from_review": + # Validate that review is complete + if (self.approved_df is None or self.approved_df.empty) and ( + self.needs_review_df is None or self.needs_review_df.empty + ): + self.app.notify( + "No data to continue with! Please review and keep some executions.", + severity="error", + ) + else: + # Save the reviewed data before continuing + self._save_reviewed_data() + self._build_paths_and_publishers() + + elif button_id == "build_preflight": + # Validate that path review is complete + if (self.primary_paths_df is None or self.primary_paths_df.empty) and ( + self.publishers_df is None or self.publishers_df.empty + ): + self.app.notify( + "No paths or publishers to build preflight with!", severity="error" + ) + else: + self._build_preflight() + + elif button_id == "liftoff": + # Confirm before applying + self.app.notify("Applying changes...", severity="information") + self._apply_changes() + + elif button_id == "workflow_done": + self.app.pop_screen() + + def _select_all_rows(self) -> None: + """Select all rows in the current review table.""" + table_id = None + df = None + if self.current_review_type == "approved": + table_id = "approved_review_table" + df = self.approved_df + else: + table_id = "needs_review_table" + df = self.needs_review_df + + if table_id and df is not None: + self.selected_rows = set(str(i) for i in range(len(df))) + # Refresh checkbox display + self._refresh_table_checkboxes(table_id, self.selected_rows) + self.app.notify(f"Selected all {len(self.selected_rows)} rows", timeout=1) + + def _select_none_rows(self) -> None: + """Clear all row selections in the current review table.""" + self.selected_rows.clear() + # Refresh checkbox display + table_id = ( + "approved_review_table" + if self.current_review_type == "approved" + else "needs_review_table" + ) + self._refresh_table_checkboxes(table_id, self.selected_rows) + self.app.notify("Cleared selection", timeout=1) + + def _select_all_path_rows(self) -> None: + """Select all rows in the current path review table.""" + table_id = None + if self.current_path_review_type == "paths": + table_id = "paths_review_table" + total = 0 + if self.primary_paths_df is not None: + total += len(self.primary_paths_df) + if self.secondary_paths_df is not None: + total += len(self.secondary_paths_df) + self.selected_path_rows = set(str(i) for i in range(total)) + elif ( + self.current_path_review_type == "publishers" + and self.publishers_df is not None + ): + table_id = "publishers_review_table" + self.selected_path_rows = set( + str(i) for i in range(len(self.publishers_df)) + ) + + # Refresh checkbox display + if table_id: + self._refresh_table_checkboxes(table_id, self.selected_path_rows) + self.app.notify(f"Selected all {len(self.selected_path_rows)} items", timeout=1) + + def _select_none_path_rows(self) -> None: + """Clear all row selections in the current path review table.""" + self.selected_path_rows.clear() + # Refresh checkbox display + table_id = ( + "paths_review_table" + if self.current_path_review_type == "paths" + else "publishers_review_table" + ) + self._refresh_table_checkboxes(table_id, self.selected_path_rows) + self.app.notify("Cleared selection", timeout=1) + + def _save_reviewed_data(self) -> None: + """Save the reviewed dataframes to the Approved folder.""" + if not self.source_policies: + return + + policy_name = self.source_policies[0].name + approved_dir = os.path.join(self.working_dir, "Approved") + os.makedirs(approved_dir, exist_ok=True) + + # Save approved executions + if self.approved_df is not None and not self.approved_df.empty: + filepath = os.path.join( + approved_dir, f"{policy_name}_approved_executions.csv" + ) + self.approved_df.to_csv(filepath, index=False) + logger.info(f"Saved approved executions to {filepath}") + + # Save needs_review as approved (since user reviewed them) + if self.needs_review_df is not None and not self.needs_review_df.empty: + filepath = os.path.join( + approved_dir, f"{policy_name}_needs_review_executions.csv" + ) + self.needs_review_df.to_csv(filepath, index=False) + logger.info(f"Saved reviewed executions to {filepath}") + + def _export_review_data(self) -> None: + """Export current review data to CSV.""" + if not self.source_policies: + return + + policy_name = self.source_policies[0].name + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + if self.current_review_type == "approved" and self.approved_df is not None: + filepath = os.path.join( + self.working_dir, f"{policy_name}_approved_export_{timestamp}.csv" + ) + self.approved_df.to_csv(filepath, index=False) + self.app.notify(f"Exported to: {filepath}", severity="information") + + elif ( + self.current_review_type == "needs_review" + and self.needs_review_df is not None + ): + filepath = os.path.join( + self.working_dir, f"{policy_name}_needs_review_export_{timestamp}.csv" + ) + self.needs_review_df.to_csv(filepath, index=False) + self.app.notify(f"Exported to: {filepath}", severity="information") + + def _export_path_review_data(self) -> None: + """Export current path review data to CSV.""" + if not self.source_policies: + return + + policy_name = self.source_policies[0].name + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + if self.current_path_review_type == "paths": + # Export both primary and secondary paths + if self.primary_paths_df is not None: + filepath = os.path.join( + self.working_dir, + f"{policy_name}_primary_paths_export_{timestamp}.csv", + ) + self.primary_paths_df.to_csv(filepath, index=False) + self.app.notify( + f"Exported primary paths to: {filepath}", severity="information" + ) + + if self.secondary_paths_df is not None: + filepath = os.path.join( + self.working_dir, + f"{policy_name}_secondary_paths_export_{timestamp}.csv", + ) + self.secondary_paths_df.to_csv(filepath, index=False) + self.app.notify( + f"Exported secondary paths to: {filepath}", severity="information" + ) + + elif ( + self.current_path_review_type == "publishers" + and self.publishers_df is not None + ): + filepath = os.path.join( + self.working_dir, f"{policy_name}_publishers_export_{timestamp}.csv" + ) + self.publishers_df.to_csv(filepath, index=False) + self.app.notify(f"Exported to: {filepath}", severity="information") + + def _open_folder(self, path: str) -> None: + """Open a folder in the system file explorer.""" + try: + import platform + import subprocess + + os.makedirs(path, exist_ok=True) + + if platform.system() == "Windows": + subprocess.Popen(f'explorer "{path}"') + elif platform.system() == "Darwin": # macOS + subprocess.Popen(["open", path]) + else: # Linux + subprocess.Popen(["xdg-open", path]) + + self.app.notify(f"Opened: {path}", severity="information") + except Exception as e: + logger.error(f"Failed to open folder: {e}") + self.app.notify(f"Failed to open folder: {str(e)}", severity="error") + + # Action handlers + def action_go_back(self) -> None: + """Handle back/escape action.""" + stage_transitions = { + "select_source": lambda: self.app.pop_screen(), + "select_destination": self._show_source_policy_selection, + "select_allowlist": self._show_destination_policy_selection, + "fetch_data": self._show_allowlist_selection, + "first_review": self._show_fetch_data, + "second_review": self._show_fetch_results, + "test": self._show_path_results, + "complete": lambda: self.app.pop_screen(), + } + + transition = stage_transitions.get(self.workflow_stage) + if transition: + transition() + else: + self.app.pop_screen() + + def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + + def action_open_folder(self) -> None: + """Open the working directory.""" + self._open_folder(self.working_dir) + + def action_refresh(self) -> None: + """Refresh the current view.""" + self._update_checklist() + + def action_delete_rows(self) -> None: + """Delete selected rows in the current table.""" + if self.workflow_stage == "first_review": + self._delete_selected_rows() + elif self.workflow_stage == "second_review": + self._delete_selected_path_rows() + + def action_select_all(self) -> None: + """Select all rows in the current table.""" + if self.workflow_stage == "first_review": + self._select_all_rows() + elif self.workflow_stage == "second_review": + self._select_all_path_rows() + + def action_select_none(self) -> None: + """Clear selection in the current table.""" + if self.workflow_stage == "first_review": + self._select_none_rows() + elif self.workflow_stage == "second_review": + self._select_none_path_rows() + + def action_toggle_selection(self) -> None: + """Toggle selection on the current row (handled by on_data_table_row_selected).""" + pass # This is handled directly in the DataTable event diff --git a/TUI/policyselectorscreen.py b/TUI/Screens/policyselectorscreen.py similarity index 82% rename from TUI/policyselectorscreen.py rename to TUI/Screens/policyselectorscreen.py index 07ec2ee..7beb704 100644 --- a/TUI/policyselectorscreen.py +++ b/TUI/Screens/policyselectorscreen.py @@ -23,9 +23,11 @@ the policy selection workflow. import logging from textual.app import ComposeResult +from textual.binding import Binding from textual.screen import Screen +from textual.widgets import Footer, Header -from TUI.policyselector import PolicySelector +from TUI.Widgets.policyselector import PolicySelector logger = logging.getLogger(__name__) @@ -42,6 +44,11 @@ class PolicySelectorScreen(Screen): agent_move_operations: Reference to the parent AgentMoveOperations widget. """ + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), + ] + CSS = """ Screen { layout: vertical; @@ -68,7 +75,18 @@ class PolicySelectorScreen(Screen): def compose(self) -> ComposeResult: """Create the PolicySelector widget.""" + yield Header(show_clock=True) yield PolicySelector(self.policies) + yield Footer() + + def action_go_back(self) -> None: + """Handle escape key to go back one screen.""" + self.app.pop_screen() + + def action_main_menu(self) -> None: + """Handle q key to go back to main menu.""" + while len(self.app.screen_stack) > 2: + self.app.pop_screen() def on_policy_selector_policy_selected( self, message: PolicySelector.PolicySelected diff --git a/TUI/quietagentworkflowscreen.py b/TUI/Screens/quietagentworkflowscreen.py similarity index 91% rename from TUI/quietagentworkflowscreen.py rename to TUI/Screens/quietagentworkflowscreen.py index 552f214..f0d5bbb 100644 --- a/TUI/quietagentworkflowscreen.py +++ b/TUI/Screens/quietagentworkflowscreen.py @@ -39,7 +39,7 @@ from textual.widgets import Button, DataTable, Footer, Header, Static from models.policy import Policy from services.API import AirlockAPIWrapper from services.policyhandler import getPolicyInfo -from TUI.policyselector import PolicySelector +from TUI.Widgets.policyselector import PolicySelector from utils.configmanager import load_env logger = logging.getLogger(__name__) @@ -69,6 +69,7 @@ class QuietAgentWorkflowScreen(Screen): BINDINGS = [ ("escape", "go_back", "Back"), + ("q", "main_menu", "Main Menu"), ] workflow_stage = reactive("select_policy") # Tracks current workflow stage @@ -96,10 +97,10 @@ class QuietAgentWorkflowScreen(Screen): def compose(self) -> ComposeResult: """Build the UI layout for the workflow screen.""" # Include Header and Footer like other standalone screens - yield Header(show_clock=True, icon="βš™") + yield Header(show_clock=True, icon="βš™οΈ") # Title area - title = Static("πŸ”’ Quiet Agent Workflow", id="workflow_title") + title = Static("Quiet Agent Workflow", id="workflow_title") title.styles.margin = (0, 0, 0, 1) yield title @@ -130,13 +131,13 @@ class QuietAgentWorkflowScreen(Screen): stage_messages = { "select_policy": "Step 1: Select Policy to Analyze", "select_quiet_days": "Step 2: Select Quiet Time Period", - "analyzing": "πŸ“Š Analyzing agent activity...", + "analyzing": "Analyzing agent activity...", "view_results": "Step 3: Review Categorized Agents", "select_enforce_target": "Step 4: Select Target Policy for Enforce Ready Agents", "select_non_enforce_target": "Step 5: Select Target Policy for Non-Enforce Ready Agents", "confirm_migration": "Step 6: Confirm and Execute Migration", - "executing": "⏳ Executing agent migrations...", - "complete": "βœ… Migration Complete", + "executing": "Executing agent migrations...", + "complete": "Migration Complete", } status_widget.update(stage_messages.get(self.workflow_stage, "Unknown Stage")) @@ -208,11 +209,6 @@ class QuietAgentWorkflowScreen(Screen): btn.styles.margin = (0, 0, 1, 0) button_container.mount(btn) - back_btn = Button("← Back", id="back_to_policy_selection") - back_btn.styles.width = "100%" - back_btn.styles.margin = (2, 0, 0, 0) - button_container.mount(back_btn) - def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button press events.""" button_id = event.button.id @@ -226,14 +222,6 @@ class QuietAgentWorkflowScreen(Screen): return # Navigation buttons - if button_id == "back_to_policy_selection": - self._show_policy_selection() - return - - if button_id == "back_to_results": - self._show_results() - return - if button_id == "select_enforce_target_btn": self._show_enforce_target_selection() return @@ -278,16 +266,16 @@ class QuietAgentWorkflowScreen(Screen): # Show analyzing message with detailed steps analyzing_msg = Static( - f"πŸ“Š Analyzing Agent Activity\n" + f"Analyzing Agent Activity\n" f"{'=' * 50}\n\n" f"Policy: {self.selected_policy.name}\n" f"History Period: {self.history_days} days\n" f"Quiet Threshold: {self.quiet_days} days\n\n" f"Progress:\n" - f"⏳ Step 1/4: Fetching agents from policy...\n" - f"⏱️ Step 2/4: Pulling execution history (this may take a moment)...\n" - f"⏱️ Step 3/4: Analyzing activity patterns...\n" - f"⏱️ Step 4/4: Categorizing agents...\n\n" + f"Step 1/4: Fetching agents from policy...\n" + f"Step 2/4: Pulling execution history (this may take a moment)...\n" + f"Step 3/4: Analyzing activity patterns...\n" + f"Step 4/4: Categorizing agents...\n\n" f"Please wait - this operation cannot be cancelled.", id="analyzing_message", ) @@ -405,8 +393,8 @@ class QuietAgentWorkflowScreen(Screen): self.agents_df = agents # Categorize agents into DataFrames - self.enforce_ready_df = agents[agents["enforce_ready"] == True].copy() - self.non_enforce_ready_df = agents[agents["enforce_ready"] == False].copy() + self.enforce_ready_df = agents[agents["enforce_ready"]].copy() + self.non_enforce_ready_df = agents[not agents["enforce_ready"]].copy() logger.info( f"Analysis complete: {len(self.enforce_ready_df)} enforce ready, " @@ -435,13 +423,13 @@ class QuietAgentWorkflowScreen(Screen): # Build updated message updated_text = ( - f"πŸ“Š Analyzing Agent Activity\n" + f"Analyzing Agent Activity\n" f"{'=' * 50}\n\n" f"Policy: {self.selected_policy.name}\n" f"History Period: {self.history_days} days\n" f"Quiet Threshold: {self.quiet_days} days\n\n" f"Progress:\n" - f"βœ… {status_text}\n\n" + f"{status_text}\n\n" f"Please wait - this operation cannot be cancelled." ) @@ -469,9 +457,9 @@ class QuietAgentWorkflowScreen(Screen): summary = Static( f"Analysis Results for: {self.selected_policy.name}\n\n" - f"πŸ“Š Total Agents: {total_agents}\n" - f"βœ… Enforce Ready: {ready_count} ({ready_percentage:.1f}%)\n" - f"❌ Not Ready: {not_ready_count} ({100 - ready_percentage:.1f}%)\n\n" + f"Total Agents: {total_agents}\n" + f"Enforce Ready: {ready_count} ({ready_percentage:.1f}%)\n" + f"Not Ready: {not_ready_count} ({100 - ready_percentage:.1f}%)\n\n" f"Quiet Threshold: {self.quiet_days} days\n" f"History Period: {self.history_days} days", id="results_summary", @@ -500,11 +488,11 @@ class QuietAgentWorkflowScreen(Screen): non_enforce_btn.styles.margin = (0, 1, 1, 0) button_container.mount(non_enforce_btn) - export_btn = Button("πŸ’Ύ Export Results", id="export_results_btn") + export_btn = Button("Export Results", id="export_results_btn") export_btn.styles.margin = (0, 1, 1, 0) button_container.mount(export_btn) - start_over_btn = Button("πŸ”„ Start Over", id="start_over_btn") + start_over_btn = Button("Start Over", id="start_over_btn") start_over_btn.styles.margin = (0, 0, 1, 0) button_container.mount(start_over_btn) @@ -520,7 +508,7 @@ class QuietAgentWorkflowScreen(Screen): enforce_col.styles.margin = (1, 1, 0, 0) tables_container.mount(enforce_col) - enforce_label = Static("βœ… Enforce Ready Agents") + enforce_label = Static("Enforce Ready Agents") enforce_label.styles.margin = (0, 0, 1, 0) enforce_col.mount(enforce_label) @@ -548,7 +536,7 @@ class QuietAgentWorkflowScreen(Screen): non_enforce_col.styles.margin = (1, 0, 0, 1) tables_container.mount(non_enforce_col) - non_enforce_label = Static("❌ Non-Enforce Ready Agents") + non_enforce_label = Static("Non-Enforce Ready Agents") non_enforce_label.styles.margin = (0, 0, 1, 0) non_enforce_col.mount(non_enforce_label) @@ -589,7 +577,7 @@ class QuietAgentWorkflowScreen(Screen): content.mount(policy_selector) # Skip button - skip_btn = Button("⭕️ Skip - No Migration", id="skip_enforce_target_btn") + skip_btn = Button("Skip - No Migration", id="skip_enforce_target_btn") skip_btn.styles.width = "50%" skip_btn.styles.margin = (2, 0, 0, 0) content.mount(skip_btn) @@ -614,7 +602,7 @@ class QuietAgentWorkflowScreen(Screen): content.mount(policy_selector) # Skip button - skip_btn = Button("⭕️ Skip - No Migration", id="skip_non_enforce_target_btn") + skip_btn = Button("Skip - No Migration", id="skip_non_enforce_target_btn") skip_btn.styles.width = "50%" skip_btn.styles.margin = (2, 0, 0, 0) content.mount(skip_btn) @@ -627,29 +615,29 @@ class QuietAgentWorkflowScreen(Screen): # Build confirmation message confirmation_lines = [ - "πŸ” Migration Summary\n", + "Migration Summary\n", f"Source Policy: {self.selected_policy.name}\n", ] if self.enforce_ready_target_policy: confirmation_lines.append( - f"\nβœ… Enforce Ready Migration:\n" - f" β€’ Agents: {len(self.enforce_ready_df)}\n" - f" β€’ Target: {self.enforce_ready_target_policy.name}\n" + f"\nEnforce Ready Migration:\n" + f"Agents: {len(self.enforce_ready_df)}\n" + f"Target: {self.enforce_ready_target_policy.name}\n" ) if self.non_enforce_ready_target_policy: confirmation_lines.append( - f"\n❌ Non-Enforce Ready Migration:\n" - f" β€’ Agents: {len(self.non_enforce_ready_df)}\n" - f" β€’ Target: {self.non_enforce_ready_target_policy.name}\n" + f"\nNon-Enforce Ready Migration:\n" + f"Agents: {len(self.non_enforce_ready_df)}\n" + f"Target: {self.non_enforce_ready_target_policy.name}\n" ) if ( not self.enforce_ready_target_policy and not self.non_enforce_ready_target_policy ): - confirmation_lines.append("\n⚠️ No migrations will be performed.") + confirmation_lines.append("\nNo migrations will be performed.") confirmation = Static("".join(confirmation_lines), id="migration_confirmation") confirmation.styles.margin = (1, 1, 2, 1) @@ -662,11 +650,11 @@ class QuietAgentWorkflowScreen(Screen): content.mount(button_container) if self.enforce_ready_target_policy or self.non_enforce_ready_target_policy: - confirm_btn = Button("βœ… Confirm Migration", id="confirm_migration_btn") + confirm_btn = Button("Confirm Migration", id="confirm_migration_btn") confirm_btn.styles.margin = (0, 1, 0, 0) button_container.mount(confirm_btn) - cancel_btn = Button("❌ Cancel", id="cancel_migration_btn") + cancel_btn = Button("Cancel", id="cancel_migration_btn") button_container.mount(cancel_btn) def _execute_migration(self) -> None: @@ -677,7 +665,7 @@ class QuietAgentWorkflowScreen(Screen): # Show executing message executing_msg = Static( - "⏳ Executing agent migrations...\nPlease wait...", + "Executing agent migrations...\nPlease wait...", id="executing_message", ) executing_msg.styles.margin = (2, 1) @@ -696,7 +684,7 @@ class QuietAgentWorkflowScreen(Screen): if self.enforce_ready_target_policy: for idx, row in self.enforce_ready_df.iterrows(): try: - result = self.api.agent_move( + self.api.agent_move( row["agentid"], self.enforce_ready_target_policy.groupid ) successful_migrations.append( @@ -713,7 +701,7 @@ class QuietAgentWorkflowScreen(Screen): if self.non_enforce_ready_target_policy: for idx, row in self.non_enforce_ready_df.iterrows(): try: - result = self.api.agent_move( + self.api.agent_move( row["agentid"], self.non_enforce_ready_target_policy.groupid ) successful_migrations.append( @@ -749,7 +737,7 @@ class QuietAgentWorkflowScreen(Screen): ) results = Static( - f"βœ… Migration Complete\n\n" + f"Migration Complete\n\n" f"Total Agents Migrated: {len(successful)}\n" f"Failed Migrations: {len(failed)}\n" f"Success Rate: {success_rate:.1f}%", @@ -764,7 +752,7 @@ class QuietAgentWorkflowScreen(Screen): success_container.styles.margin = (0, 1) content.mount(success_container) - success_label = Static("βœ… Successful Migrations") + success_label = Static("Successful Migrations") success_label.styles.margin = (0, 0, 1, 0) success_container.mount(success_label) @@ -785,7 +773,7 @@ class QuietAgentWorkflowScreen(Screen): failed_container.styles.margin = (2, 1, 0, 1) content.mount(failed_container) - failed_label = Static("❌ Failed Migrations") + failed_label = Static("Failed Migrations") failed_label.styles.margin = (0, 0, 1, 0) failed_container.mount(failed_label) @@ -802,7 +790,7 @@ class QuietAgentWorkflowScreen(Screen): failed_container.mount(failed_table) # Action button - done_btn = Button("βœ” Done", id="start_over_btn") + done_btn = Button("Done", id="start_over_btn") done_btn.styles.width = "50%" done_btn.styles.margin = (2, 0, 0, 0) content.mount(done_btn) @@ -846,3 +834,8 @@ class QuietAgentWorkflowScreen(Screen): self._show_non_enforce_target_selection() else: self.app.pop_screen() + + def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + self.app.pop_screen() diff --git a/TUI/TUI.py b/TUI/TUI.py index 2c5d4fc..1a8ce68 100644 --- a/TUI/TUI.py +++ b/TUI/TUI.py @@ -1,6 +1,20 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + import logging import os -import sys from typing import Optional import dotenv @@ -19,24 +33,22 @@ from textual.widgets import ( Tabs, ) -from flows.otp import otp_revoke -from flows.prepPolicy import menu_policy_enforce from models.agent import Agent from models.policy import Policy from services.API import AirlockAPIWrapper -from services.policyhandler import confirmUpdateAfromE -from TUI.agentmoveoperations import AgentMoveOperations -from TUI.moveagentworkflowscreen import MoveAgentWorkflowScreen -from TUI.multiagentselector import MultiAgentSelector -from TUI.OTP_generate import OTPGenerator -from TUI.otpactivityscreen import OTPActivitiesScreen -from TUI.otpworkflowscreen import OTPWorkflowScreen -from TUI.policytreewidget import PolicyTreeWidget -from TUI.quietagentworkflowscreen import QuietAgentWorkflowScreen -from TUI.resultsdisplay import ResultsDisplay -from TUI.theme_amber_terminal import get_amber_terminal_theme -from TUI.theme_retro_terminal import get_retro_terminal_theme -from TUI.themeselector import ThemeSelector +from TUI.Screens.moveagentworkflowscreen import MoveAgentWorkflowScreen +from TUI.Screens.otpactivityscreen import OTPActivitiesScreen +from TUI.Screens.otprevokescreen import OTPRevokeScreen +from TUI.Screens.otpworkflowscreen import OTPWorkflowScreen +from TUI.Screens.policyprepworkflowscreen import PolicyPrepWorkflowScreen +from TUI.Screens.quietagentworkflowscreen import QuietAgentWorkflowScreen +from TUI.Themes.theme_amber_terminal import get_amber_terminal_theme +from TUI.Themes.theme_retro_terminal import get_retro_terminal_theme +from TUI.Themes.themeselector import ThemeSelector +from TUI.Widgets.agentmoveoperations import AgentMoveOperations +from TUI.Widgets.multiagentselector import MultiAgentSelector +from TUI.Widgets.policytreewidget import PolicyTreeWidget +from TUI.Widgets.resultsdisplay import ResultsDisplay from utils.configmanager import get_user_value, load_env, save_user_config from utils.setup import get_base_directory from utils.utils import open_directory @@ -47,7 +59,7 @@ dotenv.load_dotenv() # GLOBAL STASH # --------------------------------------------------------------------------- -_PENDING_JOB = None +_APP_RESTART_REASON = None logger = logging.getLogger(__name__) @@ -83,13 +95,12 @@ class MainMenuScreen(Screen): "πŸ–₯️ - Find, Move, or Generate OTP for Agents", "move_agent_workflow_button", ), - ("πŸ“Š - Review and appove OTP Activities", "otp_activities_button"), - ("πŸ“‡ - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), + ("🎫 - Review and appove OTP Activities", "otp_activities_button"), + ("πŸ”• - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), ], "policy": [ - ("πŸ”’ - Prepare Policy For Enforcement", "policy_prep_button"), - ("πŸ”„ - Update Audit Policies", "policy_audit_update_button"), - ("❌ - Revoke OTPs", "otp_revoke_button"), + ("βš–οΈ - Prepare Policy For Enforcement", "policy_prep_button"), + ("πŸ›‘ - Revoke OTPs", "otp_revoke_button"), ], } @@ -128,7 +139,6 @@ class MainMenuScreen(Screen): yield Footer() def on_mount(self) -> None: - api = self.app.api self.switch_tab("agent_actions") # focus helpers @@ -191,42 +201,20 @@ class MainMenuScreen(Screen): self, message: MultiAgentSelector.AgentsSelected ) -> None: """Handle selected agents from AgentSelector.""" - global _PENDING_JOB + global _APP_RESTART_REASON selected_agents = message.selected_agents logger.info("Selected agents: %s", selected_agents) # TODO: Implement actual handling of selected agents - _PENDING_JOB = ("multi_agent_action", selected_agents) + _APP_RESTART_REASON = ("multi_agent_action", selected_agents) self.app.exit() def on_theme_selector_theme_selected( self, message: ThemeSelector.ThemeSelected ) -> None: """Handle theme selection from ThemeSelector.""" - global _PENDING_JOB + global _APP_RESTART_REASON _persist_user_theme(message.theme_name) - _PENDING_JOB = ("restart",) - self.app.exit() - - def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: - """Handle OTP generation request from the workflow.""" - global _PENDING_JOB - - logger.info( - "OTP Generation requested: %d devices, requestor=%s, reason=%s, duration=%d", - len(message.devices), - message.requestor, - message.reasoning, - message.duration, - ) - - _PENDING_JOB = ( - "otp_workflow", - message.devices, - message.requestor, - message.reasoning, - message.duration, - ) - + _APP_RESTART_REASON = ("restart",) self.app.exit() def on_agent_move_operations_operation_complete( @@ -282,7 +270,6 @@ class MainMenuScreen(Screen): self.app.bell() def on_button_pressed(self, event: Button.Pressed) -> None: - global _PENDING_JOB button_id = event.button.id logger.debug("Button pressed: %s", button_id) @@ -308,27 +295,23 @@ class MainMenuScreen(Screen): return case "otp_revoke_button": - _PENDING_JOB = ("legacy", otp_revoke, (self.app.api,), {}) + self.app.push_screen(OTPRevokeScreen()) + event.stop() + return case "policy_prep_button": - _PENDING_JOB = ("legacy", menu_policy_enforce, (self.app.api,), {}) - - case "policy_audit_update_button": - _PENDING_JOB = ("legacy", confirmUpdateAfromE, (self.app.api,), {}) + # Use the new TUI workflow screen instead of legacy + self.app.push_screen( + PolicyPrepWorkflowScreen(self.app.api, self.app.policies) + ) + event.stop() + return case _: self.app.bell() logger.warning("Unknown button pressed: %s", button_id) return - # Only exit the UI loop when we explicitly queued a legacy job. - # The original flow used `self.app.exit()` after setting _PENDING_JOB so - # the outer loop could run legacy code. Keep that behavior only for legacy jobs. - logger.debug("Set _PENDING_JOB = %r", _PENDING_JOB) - if _PENDING_JOB and _PENDING_JOB[0] == "legacy": - # let the main loop pick up the legacy job - self.app.exit() - # --------------------------------------------------------------------------- # 2) APP @@ -353,7 +336,7 @@ class Loxide(App[Message]): ] def __init__(self, api: AirlockAPIWrapper): - self._textual_theme = get_user_value("TEXTUAL_THEME", str, "nord") + self._textual_theme = get_user_value("TEXTUAL_THEME", str, "textual-dark") super().__init__() self.api = api wd = load_env("WORKING_DIR") or os.getcwd() @@ -395,8 +378,8 @@ class Loxide(App[Message]): self.refresh_data() def action_quit(self) -> None: - global _PENDING_JOB - _PENDING_JOB = None + global _APP_RESTART_REASON + _APP_RESTART_REASON = None self.exit() def action_open_fe(self) -> None: @@ -410,45 +393,10 @@ class Loxide(App[Message]): # --------------------------------------------------------------------------- -# 3) TERMINAL + LEGACY -# --------------------------------------------------------------------------- -def _restore_terminal_for_legacy() -> None: - sys.stdout.write("\033[?1049l") - sys.stdout.write("\033[?25h") - sys.stdout.write("\033[0m") - sys.stdout.write("\033[?1000l\033[?1002l\033[?1003l\033[?1006l") - sys.stdout.write("\033[2J\033[H") - sys.stdout.flush() - if os.name == "nt": - try: - import ctypes - - kernel32 = ctypes.windll.kernel32 - handle = kernel32.GetStdHandle(-11) - mode = ctypes.c_ulong() - if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): - kernel32.SetConsoleMode(handle, mode.value | 0x0004) - except Exception as exc: - logger.debug("VT enable on Windows failed: %s", exc) - - -def _run_legacy_job(func, args, kwargs) -> None: - logger.debug("Running legacy job: %s", getattr(func, "__name__", func)) - _restore_terminal_for_legacy() - try: - func(*args, **kwargs) - finally: - try: - input("\nPress Enter to return to the UI...") - except EOFError: - pass - - -# --------------------------------------------------------------------------- -# 4) PUBLIC ENTRYPOINT +# 3) PUBLIC ENTRYPOINT # --------------------------------------------------------------------------- def run_Loxide(api: AirlockAPIWrapper) -> None: - global _PENDING_JOB + global _APP_RESTART_REASON base_dir = get_base_directory() env_path = base_dir / ".env" dotenv.load_dotenv(dotenv_path=env_path, override=True) @@ -458,8 +406,8 @@ def run_Loxide(api: AirlockAPIWrapper) -> None: while attempts < max_attempts: attempts += 1 - logger.debug("Starting job loop iteration (attempt %d)", attempts) - _PENDING_JOB = None + logger.debug("Starting app loop iteration (attempt %d)", attempts) + _APP_RESTART_REASON = None app = Loxide(api) try: @@ -469,42 +417,27 @@ def run_Loxide(api: AirlockAPIWrapper) -> None: logger.debug("Caught SystemExit from Textual: %s", exc) raise - job = _PENDING_JOB - logger.debug("After app.run(), _PENDING_JOB = %r", job) + reason = _APP_RESTART_REASON + logger.debug("After app.run(), _APP_RESTART_REASON = %r", reason) - if not job: - logger.debug("No job pending, exiting loop") + if not reason: + logger.debug("No restart reason, exiting loop") break - if job[0] == "legacy": - _, func, args, kwargs = job - _run_legacy_job(func, args, kwargs) + if reason[0] == "restart": + logger.debug("Restarting app loop") continue - if job[0] == "restart": - logger.debug("Restarting job loop") + if reason[0] == "multi_agent_action": + logger.info("Multi-agent action with selected agents: %s", reason[1]) continue - if job[0] == "multi_agent_action": - logger.info("Multi-agent action with selected agents: %s", job[1]) - continue - - if job[0] == "otp_workflow": - _, devices, requestor, reasoning, duration = job - - def otp_generate_with_params(): - # Your OTP logic here - pass - - _run_legacy_job(otp_generate_with_params, (), {}) - continue - - logger.error("Unknown job type: %r", job) + logger.error("Unknown restart reason: %r", reason) break # --------------------------------------------------------------------------- -# 5) DEV +# 4) DEV # --------------------------------------------------------------------------- if __name__ == "__main__": api = AirlockAPIWrapper() diff --git a/TUI/theme_amber_terminal.py b/TUI/Themes/theme_amber_terminal.py similarity index 55% rename from TUI/theme_amber_terminal.py rename to TUI/Themes/theme_amber_terminal.py index 3662892..738be38 100644 --- a/TUI/theme_amber_terminal.py +++ b/TUI/Themes/theme_amber_terminal.py @@ -1,3 +1,18 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + from textual.color import Color from textual.theme import Theme diff --git a/TUI/theme_retro_terminal.py b/TUI/Themes/theme_retro_terminal.py similarity index 50% rename from TUI/theme_retro_terminal.py rename to TUI/Themes/theme_retro_terminal.py index e0cd9c9..1de2669 100644 --- a/TUI/theme_retro_terminal.py +++ b/TUI/Themes/theme_retro_terminal.py @@ -1,3 +1,18 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + from textual.color import Color diff --git a/TUI/themeselector.py b/TUI/Themes/themeselector.py similarity index 68% rename from TUI/themeselector.py rename to TUI/Themes/themeselector.py index bcbbb99..b21bb9d 100644 --- a/TUI/themeselector.py +++ b/TUI/Themes/themeselector.py @@ -1,3 +1,18 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + from textual.containers import Vertical from textual.message import Message from textual.widget import Widget diff --git a/TUI/OTP_generate.py b/TUI/Widgets/OTP_generate.py similarity index 92% rename from TUI/OTP_generate.py rename to TUI/Widgets/OTP_generate.py index 4ca7977..4dacdf6 100644 --- a/TUI/OTP_generate.py +++ b/TUI/Widgets/OTP_generate.py @@ -1,3 +1,18 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + import logging from typing import List, Optional @@ -23,6 +38,8 @@ logger = logging.getLogger(__name__) class OTPGenerator(Widget): + """Widget for generating OTPs for selected devices.""" + # Reactive properties to track form completion requestor_filled = reactive(False) reasoning_filled = reactive(False) @@ -139,14 +156,10 @@ class OTPGenerator(Widget): button_row.styles.height = "auto" button_row.styles.margin = (1, 0, 0, 0) - back_button = Button("← Back", id="back_button") - back_button.styles.width = "1fr" - yield back_button - generate_button = Button( "Generate OTP", id="generate_button", variant="primary" ) - generate_button.styles.width = "2fr" + generate_button.styles.width = "100%" yield generate_button # Right side - Show device list initially, then output after generation @@ -169,7 +182,7 @@ class OTPGenerator(Widget): # Show device list initially device_list_text = "\n".join( - f"β€’ {device.hostname}" for device in self.devices + f"{device.hostname}" for device in self.devices ) device_display = Static(device_list_text, id="device_display") yield device_display @@ -197,14 +210,7 @@ class OTPGenerator(Widget): def on_button_pressed(self, event: Button.Pressed): btn_id = event.button.id - if btn_id == "back_button": - - while len(self.app.screen_stack) > 2: - self.app.pop_screen() - - event.stop() - - elif btn_id == "copy_clipboard_button": + if btn_id == "copy_clipboard_button": try: output_area = self.query_one("#otp_output", TextArea) text_to_copy = output_area.text @@ -213,7 +219,7 @@ class OTPGenerator(Widget): pyperclip.copy(text_to_copy) self.app.notify( - "βœ… Copied to clipboard!", severity="information", timeout=2 + "βœ“ Copied to clipboard!", severity="information", timeout=2 ) except ImportError: self.app.notify( diff --git a/TUI/agentmoveoperations.py b/TUI/Widgets/agentmoveoperations.py similarity index 90% rename from TUI/agentmoveoperations.py rename to TUI/Widgets/agentmoveoperations.py index 26032a0..bf20a82 100644 --- a/TUI/agentmoveoperations.py +++ b/TUI/Widgets/agentmoveoperations.py @@ -1,3 +1,19 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + from dataclasses import asdict from datetime import datetime import logging @@ -10,12 +26,12 @@ from textual.css.query import NoMatches from textual.message import Message from textual.reactive import reactive from textual.widget import Widget -from textual.widgets import Button, DataTable, Header, Static, TextArea +from textual.widgets import Button, DataTable, Footer, Header, Static, TextArea from models.agent import Agent -from TUI.OTP_generate import OTPGenerator -from TUI.otpworkflowscreen import OTPWorkflowScreen -from TUI.policyselectorscreen import PolicySelectorScreen +from TUI.Screens.otpworkflowscreen import OTPWorkflowScreen +from TUI.Screens.policyselectorscreen import PolicySelectorScreen +from TUI.Widgets.OTP_generate import OTPGenerator logger = logging.getLogger(__name__) @@ -183,21 +199,21 @@ class AgentMoveOperations(Widget): f"Operation: {operation_name}", f"{'=' * 50}", "", - f"Òœ… Successful ({len(successful)}):", + f"βœ… Successful ({len(successful)}):", ] if successful: for agent, result in successful: - results_lines.append(f" Òœ… {agent.hostname}") + results_lines.append(f" βœ… {agent.hostname}") else: results_lines.append(" (none)") results_lines.append("") - results_lines.append(f"ҝŒ Failed ({len(unsuccessful)}):") + results_lines.append(f"❌ Failed ({len(unsuccessful)}):") if unsuccessful: for agent, error in unsuccessful: - results_lines.append(f" ҝŒ {agent.hostname}: {error}") + results_lines.append(f" ❌ {agent.hostname}: {error}") else: results_lines.append(" (none)") @@ -232,9 +248,9 @@ class AgentMoveOperations(Widget): - Operations panel: 1/3 width - Results area: Initially hidden, shown after operation completion """ - yield Header(show_clock=True, icon="Γ’Ε‘β„’") + yield Header(show_clock=True, icon="βš™οΈ") title_text = Static( - f"ðŸ–Β₯️ Agent Operations - {len(self.agents)} device(s) selected", + f"πŸ–₯️ Agent Operations - {len(self.agents)} device(s) selected", id="move_ops_title", ) title_text.styles.margin = (0, 0, 1, 0) @@ -269,32 +285,32 @@ class AgentMoveOperations(Widget): yield operations_label # Operation buttons - export_csv_btn = Button("Γ°ΕΈβ€œΛ† Export CSV", id="export_csv_btn") + export_csv_btn = Button("πŸ“„ Export CSV", id="export_csv_btn") export_csv_btn.styles.width = "100%" export_csv_btn.styles.margin = (0, 0, 1, 0) yield export_csv_btn local_approval_btn = Button( - "Òœ”️ Local Approval Mode", id="local_approval_btn" + "βœ”οΈ Local Approval Mode", id="local_approval_btn" ) local_approval_btn.styles.width = "100%" local_approval_btn.styles.margin = (0, 0, 1, 0) yield local_approval_btn - otp_gen_btn = Button("Γ°ΕΈΕ½Β« Generate One Time Passes", id="otp_gen_btn") + otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn") otp_gen_btn.styles.width = "100%" otp_gen_btn.styles.margin = (0, 0, 1, 0) yield otp_gen_btn toggle_enforcement_btn = Button( - "Γ°ΕΈβ€β€ž Toggle Audit/Enforcement", id="toggle_enforcement_btn" + "πŸ”„ Toggle Audit/Enforcement", id="toggle_enforcement_btn" ) toggle_enforcement_btn.styles.width = "100%" toggle_enforcement_btn.styles.margin = (0, 0, 1, 0) yield toggle_enforcement_btn other_policy_btn = Button( - "🔀 Move to Other Policy", id="other_policy_btn" + "πŸ”€ Move to Other Policy", id="other_policy_btn" ) other_policy_btn.styles.width = "100%" other_policy_btn.styles.margin = (0, 0, 1, 0) @@ -305,10 +321,7 @@ class AgentMoveOperations(Widget): status_label.styles.margin = (2, 0, 0, 0) yield status_label - back_button = Button("Ò† Back", id="back_button") - back_button.styles.width = "50%" - back_button.styles.margin = (0, 1, 1, 0) - yield back_button + yield Footer() def on_mount(self) -> None: """ @@ -343,7 +356,7 @@ class AgentMoveOperations(Widget): Handle button press events from the widget. This Textual event handler routes button presses to appropriate actions: - - back_button: Pop this screen (return to parent) + - copy_results_btn: Copy results text to clipboard (requires pyperclip) - local_approval_btn: Start local approval operation - toggle_enforcement_btn: Start toggle audit/enforcement operation @@ -357,29 +370,24 @@ class AgentMoveOperations(Widget): btn_id = event.button.id - if btn_id == "back_button": - while len(self.app.screen_stack) > 2: - self.app.pop_screen() - event.stop() - - elif btn_id == "copy_results_btn": + if btn_id == "copy_results_btn": try: results_text = self.query_one("#results_text", TextArea) import pyperclip pyperclip.copy(results_text.text) self.app.notify( - "Γ°ΕΈβ€œβ€ΉΓ’Ε“β€¦ Results copied to clipboard!", + "πŸ“‹βœ… Results copied to clipboard!", severity="information", timeout=2, ) except ImportError: self.app.notify( - "ҝŒ pyperclip not installed. Run: pip install pyperclip", + "❌ pyperclip not installed. Run: pip install pyperclip", severity="warning", ) except Exception as e: - self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") + self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") event.stop() elif btn_id == "export_csv_btn": self._start_export_csv_operation() @@ -427,7 +435,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("Òœ”️ Moving agents to local approval...") + status_label.update("βœ”οΈ Moving agents to local approval...") # Get API from app api = self.app.api @@ -462,12 +470,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during local approval operation: {e}") - status_label.update(f"ҝŒ Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("Òœ… Operation complete!") + status_label.update("βœ… Operation complete!") # Display results in the widget self._display_results("Local Approval Mode", successful, unsuccessful) @@ -483,7 +491,6 @@ class AgentMoveOperations(Widget): self.selected_operation = "export_csv" self.operation_in_progress = True successful = [] - unsuccessful = [] status_label = self.query_one("#status_label", Static) status_label.update("Exporting CSV...") self.app.refresh_data() @@ -511,9 +518,9 @@ class AgentMoveOperations(Widget): file_path = os.path.join(str(path), filename) df.to_csv(file_path, index=False) successful.append(file_path) - status_label.update(f"Òœ… Exported to {file_path}") + status_label.update(f"βœ… Exported to {file_path}") except Exception: - status_label.update("ҝŒ Failed") + status_label.update("❌ Failed") self.operation_in_progress = False @@ -557,7 +564,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("ҏ³ Toggling enforcement mode...") + status_label.update("πŸ”„ Toggling enforcement mode...") # Get API from app api = self.app.api @@ -593,12 +600,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during toggle enforcement operation: {e}") - status_label.update(f"ҝŒ Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("ΓƒΒ’Γ…β€œΓ’β‚¬Β¦ Operation complete!") + status_label.update("βœ… Operation complete!") # Display results in the widget self._display_results("Toggle Audit/Enforcement", successful, unsuccessful) @@ -663,7 +670,7 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error loading policies: {e}") - status_label.update(f"ҝŒ Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False self.selected_operation = "" self.app.notify(f"Failed to load policies: {str(e)}", severity="error") @@ -695,7 +702,7 @@ class AgentMoveOperations(Widget): for agent in self.agents: try: # Move agent to target policy - result = api.agent_move(agent.agentid, target_policy.groupid) + api.agent_move(agent.agentid, target_policy.groupid) successful.append((agent, f"Moved to {target_policy.name}")) logger.info( f"Successfully moved {agent.hostname} to policy {target_policy.name}" diff --git a/TUI/multiagentselector.py b/TUI/Widgets/multiagentselector.py similarity index 88% rename from TUI/multiagentselector.py rename to TUI/Widgets/multiagentselector.py index afe8e14..bc77706 100644 --- a/TUI/multiagentselector.py +++ b/TUI/Widgets/multiagentselector.py @@ -1,3 +1,18 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + import difflib import re from typing import List, Optional @@ -20,6 +35,8 @@ from models.agent import Agent class MultiAgentSelector(Widget): + """Widget for selecting multiple agents from a list.""" + class AgentsSelected(Message): def __init__(self, selected_agents: List[Agent]): super().__init__() @@ -40,7 +57,7 @@ class MultiAgentSelector(Widget): def compose(self): yield Header(show_clock=True, icon="βš™") - title_text = Static("πŸ–§ Agent Selector", id="selector_title") + title_text = Static("πŸ–₯️ Agent Selector", id="selector_title") title_text.styles.margin = (0, 0, 0, 1) yield title_text @@ -60,7 +77,7 @@ class MultiAgentSelector(Widget): text_area.styles.overflow_y = "auto" yield text_area - with Horizontal(id="switch_search_container") as switch_search: + with Horizontal(id="switch_search_container"): switch = Switch(value=False, id="match_switch") switch.styles.width = "auto" switch.styles.margin = (1, 0, 0, 0) @@ -91,11 +108,6 @@ class MultiAgentSelector(Widget): button_row.styles.height = "auto" button_row.styles.margin = (1, 0, 0, 0) - back_button = Button("← Back", id="back_button") - back_button.styles.width = "1fr" - back_button.styles.margin = (0, 0, 0, 1) - yield back_button - submit_button = Button( "β–Ά Select & Continue", id="submit_selection", variant="primary" ) @@ -123,10 +135,7 @@ class MultiAgentSelector(Widget): match_list = self.query_one("#match_results", SelectionList) except NoMatches: return - if btn_id == "back_button": - self.app.pop_screen() - event.stop() - elif btn_id == "select_all": + if btn_id == "select_all": match_list.select_all() event.stop() elif btn_id == "select_none": diff --git a/TUI/policyselector.py b/TUI/Widgets/policyselector.py similarity index 92% rename from TUI/policyselector.py rename to TUI/Widgets/policyselector.py index d6e4bdf..6e0b71d 100644 --- a/TUI/policyselector.py +++ b/TUI/Widgets/policyselector.py @@ -1,10 +1,17 @@ -""" -Policy Selector Widget Module - -Provides a Textual widget for selecting target policies for bulk agent operations. -Allows users to browse available policies and select one as the destination for -moving agents. Automatically excludes parent/logical policies. -""" +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see import logging import re @@ -34,7 +41,7 @@ class PolicySelector(Widget): - Wildcard filtering (* and ?) - Interactive table for policy browsing - Explicit confirm button for selection - - Cancel/back button to dismiss + - Use escape key to go back Attributes: policies (list[Policy]): List of available Policy objects to display. @@ -91,10 +98,10 @@ class PolicySelector(Widget): - Clear Filter button - Confirm Selection button - Policy table displaying available policies - - Back buttons for navigation + - Use escape key to go back """ title_text = Static( - "🎯 Select Target Policy", + "Select Target Policy", id="policy_selector_title", ) title_text.styles.margin = (0, 0, 1, 0) @@ -125,12 +132,12 @@ class PolicySelector(Widget): filter_help.styles.margin = (0, 0, 1, 0) yield filter_help - apply_button = Button("βœ“ Apply Filter", id="filter_button") + apply_button = Button("πŸ” Apply Filter", id="filter_button") apply_button.styles.width = "100%" apply_button.styles.margin = (0, 0, 1, 0) yield apply_button - clear_button = Button("Clear Filter", id="clear_filter_button") + clear_button = Button("🧹 Clear Filter", id="clear_filter_button") clear_button.styles.width = "100%" clear_button.styles.margin = (0, 0, 1, 0) yield clear_button @@ -144,11 +151,6 @@ class PolicySelector(Widget): selected_label.styles.margin = (2, 0, 1, 0) yield selected_label - cancel_button = Button("← Back", id="back_button") - cancel_button.styles.width = "100%" - cancel_button.styles.margin = (1, 0, 1, 0) - yield cancel_button - # Right side - Policy table with Vertical() as right_side: right_side.styles.width = "2fr" @@ -222,7 +224,6 @@ class PolicySelector(Widget): Handle button press events from the widget. Routes to: - - back_button (Cancel): Pop screen without selecting - filter_button (Apply Filter): Filter policies with wildcard support - clear_filter_button: Clear filter and show all policies - confirm_button: Confirm selection and post message @@ -232,12 +233,7 @@ class PolicySelector(Widget): """ btn_id = event.button.id - if btn_id == "back_button": - while len(self.app.screen_stack) > 2: - self.app.pop_screen() - event.stop() - - elif btn_id == "filter_button": + if btn_id == "filter_button": self._apply_filter() event.stop() @@ -283,7 +279,7 @@ class PolicySelector(Widget): if self.selected_policy: # Update selection display label = self.query_one("#selected_policy_label", Static) - label.update(f"βœ“ Selected: {self.selected_policy.name}") + label.update(f"Selected: {self.selected_policy.name}") # Log for debugging logger.debug( @@ -325,7 +321,7 @@ class PolicySelector(Widget): if highlighted_name: label = self.query_one("#selected_policy_label", Static) - label.update(f"β†’ Highlighting: {highlighted_name}") + label.update(f"Highlighting: {highlighted_name}") except Exception as e: logger.error(f"Error handling row highlight: {e}") @@ -399,7 +395,9 @@ class PolicySelector(Widget): ) displayed_count = len(self._displayed_policies) - status_text = f"πŸ“Š Showing {displayed_count} of {len(self._filtered_policies)} policies" + status_text = ( + f"Showing {displayed_count} of {len(self._filtered_policies)} policies" + ) self.app.notify(status_text, severity="information", timeout=2) # Clear selection when filter is applied @@ -474,7 +472,7 @@ class PolicySelector(Widget): """ if self.selected_policy is None: self.app.notify( - "⚠️ Please select a policy first by clicking on a row in the table", + "Please select a policy first by clicking on a row in the table", severity="warning", timeout=3, ) @@ -483,6 +481,6 @@ class PolicySelector(Widget): # Log confirmation for debugging logger.info(f"Confirming selection of policy: {self.selected_policy.name}") self.app.notify( - f"βœ… Confirmed: {self.selected_policy.name}", severity="success", timeout=2 + f"Confirmed: {self.selected_policy.name}", severity="success", timeout=2 ) self.post_message(self.PolicySelected(self.selected_policy)) diff --git a/TUI/policytreewidget.py b/TUI/Widgets/policytreewidget.py similarity index 93% rename from TUI/policytreewidget.py rename to TUI/Widgets/policytreewidget.py index 5e01fdf..d51da78 100644 --- a/TUI/policytreewidget.py +++ b/TUI/Widgets/policytreewidget.py @@ -1,3 +1,18 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + from collections import defaultdict import logging diff --git a/TUI/Widgets/prepPolicy.py b/TUI/Widgets/prepPolicy.py new file mode 100644 index 0000000..f3448a0 --- /dev/null +++ b/TUI/Widgets/prepPolicy.py @@ -0,0 +1,870 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import logging +import os +import os.path +import re +from typing import List + +import dotenv +import pandas as pd + +from models.execution import ExecutionHistoryRecord +from models.policy import Allowlist, Policy +from services.API import AirlockAPIWrapper +from utils.configmanager import get_system_list, get_system_value, load_env +from utils.selector import Selector +from utils.utils import ( + areYouSure, + clear_screen, + colorText, + formatHTML, + get_sanitized_input, + locked, + open_directory, + print_x_wide, + regulator, +) + +logger = logging.getLogger(__name__) + +dotenv.load_dotenv() + + +def selectPolicies(api: AirlockAPIWrapper, allow_multiple=True) -> List[Policy]: + + policies = [Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows()] + logger.debug("Prompting for Policies") + print(colorText("Please select policy/policies", "white")) + selected = Selector.select_objects(policies, allow_multiple, prompt_each=True) + + if selected is None: + return [] + + # Normalize to always return a list + logger.debug("Returning {selected.dict}") + return selected if isinstance(selected, list) else [selected] + + +def selectAllowlists( + api: AirlockAPIWrapper, policy=all, allow_multiple=True +) -> List[Allowlist]: + if policy == "all": + allowlists = [ + Allowlist(**row.to_dict()) for _, row in api.allowlist_find_all().iterrows() + ] + else: + allowlists = [ + Allowlist(**row.to_dict()) + for _, row in api.policy_list_allowlists(policy[0].groupid).iterrows() + ] + logger.debug("Prompting for Allowlist(s)") + print(colorText("Please select allowlist(s)", "white")) + selected = Selector.select_objects(allowlists, allow_multiple, prompt_each=True) + + if selected is None: + return [] + + # Normalize to always return a list + logger.debug(f"Returning {selected}") + return selected if isinstance(selected, list) else [selected] + + +def sortHashes( + api: AirlockAPIWrapper, selected_policies: List[Policy], type=[1, 2, 6, 7] +): + working_dir = load_env("WORKING_DIR") + history_days = Selector.select_value( + prompt="Enter how many days of history to pull (1–150): ", + value_type=int, + valid_range=(1, 150), + ) + + logger.debug(f"{history_days} day selected for history") + + if history_days is None: + logging.warning("No history range selected. Aborting.") + return + + policy_executions = ExecutionHistoryRecord.from_policies( + api, selected_policies, type_=type, history_days=history_days + ) + + logger.debug(f"Executions contains {policy_executions}") + + enriched_executions = ExecutionHistoryRecord.enrich_with_hashes( + api, policy_executions + ) + categorized_executions = ( + ExecutionHistoryRecord.categorize_executions_by_hash_decision( + enriched_executions + ) + ) + approved, unapproved, needs_review, unknown = ( + ExecutionHistoryRecord.sort_by_hash_decision(categorized_executions) + ) + + categories = { + "needs_review": needs_review, + "approved": approved, + "unapproved": unapproved, + "leftover": unknown, + } + + for label, records in categories.items(): + if not records: + continue # Skip empty or falsy categories + + csv_path = f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_{label}_executions.csv" + html_path = f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{label}.html" + + # Convert ExecutionHistoryRecord objects to dictionaries + df = pd.DataFrame([r.__dict__ for r in records]) + + # Optional: flatten hash_obj if needed + if not df.empty and "hash_obj" in df.columns: + hash_df = df["hash_obj"].apply(lambda h: h.to_dict() if h else {}) + df = pd.concat([df.drop(columns=["hash_obj"]), hash_df], axis=1) + + # Save to CSV + df.to_csv(csv_path, index=False) + logger.info(f"Saved {label} executions to {csv_path}") + + # Generate HTML + formatHTML(df, html_path) + logger.info(f"Generated HTML report at {html_path}") + + +def buildPathsandPublishers(selected_policies: List[Policy], split): + working_dir = load_env("WORKING_DIR") + df1 = pd.DataFrame() + df2 = pd.DataFrame() + all_approved_hashes = pd.DataFrame() + path1 = ( + f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv" + ) + path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_needs_review_executions.csv" + path_exclusion_constant = get_system_value("PATH_EXCLUSION_CONST", cast_type=int) + + if os.path.exists(path1): + df1 = pd.read_csv(path1) + else: + logger.warning(f"File not found: {path1}") + + if os.path.exists(path2): + df2 = pd.read_csv(path2) + else: + logger.warning(f"File not found: {path2}") + + if df1.empty and df2.empty: + logger.warning("Both DataFrames are empty. Skipping sort.") + all_approved_hashes = pd.DataFrame() + logger.debug(all_approved_hashes.head) + else: + all_approved_hashes = pd.concat([df1, df2], ignore_index=True) + if "filename" in all_approved_hashes.columns: + all_approved_hashes = all_approved_hashes.sort_values(by="filename") + else: + logger.warning( + "Warning: 'filename' column not found in concatenated DataFrame." + ) + + if not all_approved_hashes.empty and path_exclusion_constant: + + primary_path_exclusions = calculatePath( + all_approved_hashes, + path_exclusion_constant, + split, + ) + remaining_hashes = all_approved_hashes[ + ~all_approved_hashes["sha256"].isin(primary_path_exclusions["sha256"]) + ] + secondary_path_exclusions = calculatePath( + remaining_hashes, (path_exclusion_constant - 1), split + ) + remaining_hashes = remaining_hashes[ + ~remaining_hashes["sha256"].isin(secondary_path_exclusions["sha256"]) + ] + dataframes = { + "all_approved_hashes": all_approved_hashes, + "primary_Paths": primary_path_exclusions, + "secondary_Paths": secondary_path_exclusions, + "hashes_not_approvable_by_path": remaining_hashes, + } + logger.debug("Preparing to sort dataframes") + for name, df in dataframes.items(): + logger.debug(f" DataFrame headers: {list(df.columns)}") + if "hashes" in name: + df.sort_values(by="filename", inplace=True) + else: + df.sort_values(by="longestcfp", inplace=True) + + df.to_csv( + f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_{name}.csv", + index=False, + ) + formatHTML( + df, + f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{name}.html", + ) + + if not all_approved_hashes.empty: + # Drop all not signed, only keep unique values + publist = all_approved_hashes[ + all_approved_hashes["publisher"] != "Not Signed" + ].drop_duplicates(subset=["publisher"]) + # Remove Bad publisher if somehow they made it this far + pattern = regulator(get_system_list("BAD_PUBLISHERS")) + publist = publist[~publist["publisher"].str.contains(pattern, na=False)] + publist = publist[["publisher"]] + publist.sort_values(by="publisher", inplace=True) + publist.to_csv( + f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_publishers.csv", + index=False, + ) + else: + logger.debug("Approved Hashes list appears empty") + + +def buildPreflights(selected_policies: List[Policy]): + working_dir = load_env("WORKING_DIR") + + df1 = pd.DataFrame() + df2 = pd.DataFrame() + approved_hashes = pd.DataFrame() + approved_publishers = pd.DataFrame() + + hash = f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_all_approved_hashes.csv" + path1 = f"{working_dir}\\Approved\\{selected_policies[0].name}_primary_Paths.csv" + path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_secondary_Paths.csv" + publishers = f"{working_dir}\\Approved\\{selected_policies[0].name}_publishers.csv" + + # Read in and combine the two path generations + if os.path.exists(path1): + df1 = pd.read_csv(path1) + else: + logger.warning(f"File not found: {path1}") + + if os.path.exists(path2): + df2 = pd.read_csv(path2) + else: + logger.warning(f"File not found: {path2}") + + if df1.empty and df2.empty: + logger.warning("Both DataFrames are empty. Skipping sort.") + approved_paths = pd.DataFrame() + else: + approved_paths = pd.concat([df1, df2], ignore_index=True) + + approved_paths = approved_paths.drop_duplicates(subset="longestcfp", keep="first") + + # We create a list of hashes that are left over if we exclude the ones that are covered by the path exclusions. + if os.path.exists(hash): + hashes = pd.read_csv(hash) + approved_hashes = hashes[~hashes["filename"].isin(approved_paths["longestcfp"])] + + approved_hashes = approved_hashes.drop_duplicates(subset="sha256", keep="first") + + else: + logger.warning(f"File not found: {hash}") + + if os.path.exists(publishers): + approved_publishers = pd.read_csv(publishers) + + else: + logger.warning(f"File not found: {publishers}") + + dataframes = { + "approved_paths": approved_paths, + "approved_hashes": approved_hashes, + "approved_publishers": approved_publishers, + } + + for name, df in dataframes.items(): + logger.debug(f" DataFrame headers: {list(df.columns)}") + if name == "approved_paths": + df.sort_values(by="longestcfp", inplace=True) + elif name == "approved_hashes": + df.sort_values(by="filename", inplace=True) + elif name == "approved_publishers": + df.sort_values(by="publisher", inplace=True) + + df.to_csv( + f"{working_dir}\\Preflight\\{selected_policies[0].name}_{name}.csv", + index=False, + ) + formatHTML( + df, + f"{working_dir}\\Preflight\\HTML\\{selected_policies[0].name}_{name}.html", + ) + + +def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"): + min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int) + + def clean_split(path): + if not isinstance(path, (str, bytes, os.PathLike)): + return [] + parts = str(os.path.normpath(path)).split(os.sep) + parts = [p for p in parts if p] # Remove empty strings + return parts + + # Diagnostic: log any non-string entries + non_string_entries = df[ + ~df[col].apply(lambda x: isinstance(x, (str, bytes, os.PathLike))) + ] + if not non_string_entries.empty: + print(f"[WARNING] Non-string entries found in column '{col}':") + print(non_string_entries) + + df = df.copy() + split_paths = df[col].apply(clean_split) + + if min_files_for_path is not None: + df = df[ + split_paths.apply(lambda parts: len(parts) >= min_files_for_path) + ].copy() + split_paths = split_paths[df.index] + + df["group_key"] = split_paths.apply( + lambda parts: os.sep.join(parts[:path_exclusion_constant]) + ) + grouped = df.groupby("group_key") + new_rows = [] + + for _, group_df in grouped: + paths = group_df[col].tolist() + split_parts = [clean_split(p) for p in paths] + + def longest_common_prefix(paths): + if not paths: + return [] + prefix = paths[0] + for path in paths[1:]: + prefix = [a for a, b in zip(prefix, path) if a == b] + if not prefix: + break + return prefix + + common_prefix = longest_common_prefix(split_parts) + prefix_str = os.sep.join(common_prefix) + + for i, parts in enumerate(split_parts): + filename = parts[-1] + middle = ( + os.sep.join(parts[len(common_prefix) : -1]) + if len(parts) > len(common_prefix) + 1 + else "" + ) + row = group_df.iloc[i].copy() + row["longestcfp"] = prefix_str + row["middle"] = middle + row["filename_only"] = filename + row["file_extension"] = os.path.splitext(filename)[1].lower() + new_rows.append(row) + + return pd.DataFrame(new_rows).drop(columns=["group_key"]) + + +def calculatePath(approved_hashes, path_exclusion_constant, split): + if split: + dfs_by_policy = [group for _, group in approved_hashes.groupby("policy")] + else: + dfs_by_policy = [approved_hashes] + + badpathparts = get_system_list("BAD_PATH_PARTS") + min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int) + + processed_dfs = [] + + for df in dfs_by_policy: + haslcp = splitFilepathsGrouped(df, path_exclusion_constant, "filename") + haslcp = haslcp.drop_duplicates() + + forbidden = regulator(badpathparts, True) + forbidden_lcfp = haslcp["longestcfp"].str.contains(forbidden, na=False) + + logger.debug("Removing forbidden filepaths for path exceptions") + print(colorText("Removing forbidden filepaths for path exceptions", "green")) + lcp_not_forbidden = haslcp[~forbidden_lcfp].copy() + + lcp_not_forbidden_review = lcp_not_forbidden[ + [ + "policyname", + "longestcfp", + "middle", + "filename_only", + "file_extension", + "sha256", + ] + ] + + unique_sha_counts = ( + lcp_not_forbidden_review.groupby("longestcfp")["sha256"] + .nunique() + .reset_index() + ) + unique_sha_counts.columns = ["longestcfp", "unique_sha256_count"] + + lcp_not_forbidden_review = lcp_not_forbidden_review.merge( + unique_sha_counts, on="longestcfp", how="left" + ) + lcp_not_forbidden_review = lcp_not_forbidden_review[ + lcp_not_forbidden_review["unique_sha256_count"] >= min_files_for_path + ] + processed_dfs.append(lcp_not_forbidden_review) + + pathExclusions = pd.concat(processed_dfs, ignore_index=True) + + return pathExclusions + + +def testChange(selected_policies, destination_policy, destination_allowlist): + working_dir = load_env("WORKING_DIR") + + logger.info("These path exclusions would be added to:") + logger.info(destination_policy) + + pathexclusions = pd.read_csv( + f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv" + ) + hashes = pd.read_csv( + f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv" + ) + + unique_combinations = pathexclusions[ + ["longestcfp", "file_extension"] + ].drop_duplicates() + + drive_letter_pattern = re.compile(r"^[a-zA-Z]:\\") + processed_paths = [ + (path if drive_letter_pattern.match(path) else f"\\\\{path}") + f"\\**{ext}" + for path, ext in unique_combinations.itertuples(index=False, name=None) + ] + + for path in processed_paths: + logger.info(path) + + print(colorText("These publishers would added", "yellow")) + processed_publishers = [] + if os.path.exists( + f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv" + ): + publishers = pd.read_csv( + f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv" + ) + if publishers.empty: + print(colorText("The publishers list is empty.", "red")) + else: + processed_publishers = ( + publishers[publishers["publisher"] != "Not Signed"]["publisher"] + .drop_duplicates() + .tolist() + ) + for publisher in processed_publishers: + print(publisher) + + print(colorText("These hashes would be added to:", "yellow")) + print(destination_allowlist) + + processed_hashes = hashes["sha256"].unique().tolist() + print_x_wide(processed_hashes, 3) + + return processed_paths, processed_hashes, processed_publishers + + +def menu_policy_enforce( + api: AirlockAPIWrapper, +): # TODO Need to clean up 6 and 7 into functions + selected_policies = [] + destination_policy = [] + destination_allowlist = [] + processed_paths = [] + processed_hashes = [] + processed_publishers = [] + working_dir = load_env("WORKING_DIR") + + while True: + printEnforceChecklist( + selected_policies, destination_policy, destination_allowlist + ) + choice = get_sanitized_input("\nEnter your choice: ") + + if choice == "1": + clear_screen() + selected_policies = selectPolicies(api, True) + + elif choice == "2": + clear_screen() + print( + colorText( + "Please choose destination_name Policy for Path Exclusions", "white" + ) + ) + + destination_policy = selectPolicies(api, False) + + print(colorText("Please choose Allowlist for Hashes", "white")) + + destination_allowlist = selectAllowlists(api, destination_policy, False) + + elif choice == "3": + clear_screen() + sortHashes( + api, + selected_policies, + type=[1, 2, 6, 7], + ) + + elif choice == "4": + clear_screen() + if os.path.exists( + f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_approved_executions.csv" + ): + buildPathsandPublishers(selected_policies, False) + else: + print( + "File not found. Please make sure it's saved correctly and try again." + ) + + elif choice == "5": + clear_screen() + if os.path.exists( + f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv" + ) and os.path.exists( + f"{working_dir}\\Approved\\{selected_policies[0].name}_primary_Paths.csv" + ): + buildPreflights(selected_policies) + else: + print( + "File not found. Please make sure it's saved correctly and try again." + ) + + elif choice == "6": + clear_screen() + if ( + os.path.exists( + f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv" + ) + and os.path.exists( + f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv" + ) + and destination_policy + and destination_allowlist + ): + processed_paths, processed_hashes, processed_publishers = testChange( + selected_policies, destination_policy, destination_allowlist + ) + else: + # Log which condition(s) failed + missing_items = [] + if not os.path.exists( + f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv" + ): + missing_items.append("approved_paths.csv not found") + if not os.path.exists( + f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv" + ): + missing_items.append("approved_hashes.csv not found") + if not destination_policy: + missing_items.append("destination_policy is empty or None") + if not destination_allowlist: + missing_items.append("destination_allowlist is empty or None") + + logger.error("Preflight check failed due to the following:") + for item in missing_items: + logger.error(f" - {item}") + + elif choice == "7": + clear_screen() + areYouSure() + confirmation = get_sanitized_input("Type 'I AGREE' to continue: ") + if ( + processed_paths + and processed_hashes + and processed_publishers + and destination_policy + and destination_allowlist + and confirmation.strip() == "I AGREE" + ): + print(colorText("Proceeding with the code...", "yellow")) + api.hash_add_to_allowlist( + destination_allowlist[0].applicationid, processed_hashes + ) + api.policy_add_path_exclusions( + destination_policy[0].groupid, processed_paths + ) + if processed_publishers: + api.policy_add_publishers( + destination_policy[0].groupid, processed_publishers + ) + + locked() + + else: + logger.error("Confirmation block failed. Reasons:") + if not processed_publishers or processed_hashes or processed_paths: + logger.error(" - Test not performed.") + if not destination_policy: + logger.error(" - `destination_policy` is missing or invalid.") + if not destination_allowlist: + logger.error(" - `destination_allowlist` is missing or invalid.") + if confirmation.strip() != "I AGREE": + logger.error( + " - User did not confirm with 'I AGREE'. Received: '%s'", + confirmation.strip(), + ) + + elif choice.upper() == "F": + open_directory(working_dir) + elif choice.upper() == "B": + break + + else: + print(colorText("Invalid choice. Please try again.", "red")) + + +def section_header(title): + print( + colorText( + "\n --------------------------------------------------------------------", + "cyan", + ) + ) + print(colorText(f" ------------- {title} -------------", "cyan")) + print( + colorText( + " --------------------------------------------------------------------", + "cyan", + ) + ) + + +def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist): + working_dir = load_env("WORKING_DIR") + section_header("Prepare to Enforce Policy ") + print( + colorText( + "\nSequentially follow these steps to prepare a policy for enforcement:", + "white", + ) + ) + + # Step 1: Originating Policies + print( + colorText( + "\n1. Choose which policy or policies to gather execution info from", "cyan" + ) + ) + if not selected_policies: + print(colorText(" [βœ—] No policies have been chosen", "red")) + else: + print(colorText("The following policies have been chosen:", "green")) + for policy in selected_policies: + print(colorText(f" [βœ“] {policy.name}", "green")) + + # Step 2: Destination Policy and Allowlist + print( + colorText("2. Choose the destination policy and associated allowlist", "cyan") + ) + if destination_policy: + print( + colorText( + f" [βœ“] {destination_policy[0].name} has been selected as the destination policy", + "green", + ) + ) + else: + print(colorText(" [βœ—] No destination policy has been chosen", "red")) + + if destination_allowlist: + print( + colorText( + f" [βœ“] {destination_allowlist[0].name} has been selected as allowlist", + "green", + ) + ) + else: + print(colorText(" [βœ—] No allowlist has been chosen", "red")) + + # Step 3: Data Preparation + print( + colorText( + f"3. Select to begin pulling execution history. The executions will be sorted and placed in {working_dir}\\data\\Needs_Review", + "cyan", + ) + ) + if selected_policies: + policy_id = selected_policies[0].name + review_path = f"{working_dir}\\Needs_Review\\Review_First\\{policy_id}_approved_executions.csv" + print( + colorText( + ( + " [βœ“] Data has been fetched" + if os.path.exists(review_path) + else " [βœ—] Data has not been fetched" + ), + "green" if os.path.exists(review_path) else "red", + ) + ) + else: + print( + colorText( + " [βœ—] No policies selected, cannot check data fetch status", "red" + ) + ) + + # Step 4: Manual Review + print(colorText("4. Manually review the files:", "cyan")) + print( + colorText( + " Remove the rows containing hashes you do not approve of", "cyan" + ) + ) + print( + colorText( + f" When complete, save both csv files to {working_dir}\\data\\Approved and choose this option.", + "cyan", + ) + ) + print( + colorText( + " This will start the process to generate possible filepath approvals", + "cyan", + ) + ) + + if selected_policies: + policy_id = selected_policies[0].name + approved_path = f"{working_dir}\\Approved\\{policy_id}_approved_executions.csv" + second_review_path = ( + f"{working_dir}\\Needs_Review\\Review_Second\\{policy_id}_primary_Paths.csv" + ) + print( + colorText( + ( + " [βœ“] Reviewed hashes have been loaded" + if os.path.exists(approved_path) + else " [βœ—] Reviewed hashes have not been loaded" + ), + "green" if os.path.exists(approved_path) else "red", + ) + ) + print( + colorText( + ( + " [βœ“] Path review list created" + if os.path.exists(second_review_path) + else " [βœ—] Path review list has not been created" + ), + "green" if os.path.exists(second_review_path) else "red", + ) + ) + else: + print( + colorText( + " [βœ—] No policies selected, cannot check reviewed hashes or path list", + "red", + ) + ) + + # Step 5: Path Review + print( + colorText( + f"5. Manually review the files in {working_dir}\\Needs_Review\\Review_Second\\", + "cyan", + ) + ) + print( + colorText( + " Remove the rows containing path exclusions or publishers you do not approve of.", + "cyan", + ) + ) + print( + colorText( + f" When complete, save the files to {working_dir}\\data\\Approved", + "cyan", + ) + ) + print( + colorText(" Choose this option when done to build your preflights", "cyan") + ) + + if selected_policies: + policy_id = selected_policies[0].name + reviewed_path = f"{working_dir}\\Approved\\{policy_id}_primary_Paths.csv" + preflight_paths = f"{working_dir}\\Preflight\\{policy_id}_approved_paths.csv" + preflight_hashes = f"{working_dir}\\Preflight\\{policy_id}_approved_hashes.csv" + print( + colorText( + ( + " [βœ“] Reviewed path list detected" + if os.path.exists(reviewed_path) + else " [βœ—] Path review list has not been detected" + ), + "green" if os.path.exists(reviewed_path) else "red", + ) + ) + preflight_ready = os.path.exists(preflight_paths) and os.path.exists( + preflight_hashes + ) + print( + colorText( + ( + " [βœ“] Preflight Path Exclusion List has been generated" + if preflight_ready + else " [βœ—] Preflight Path Exclusion List has not been generated" + ), + "green" if preflight_ready else "red", + ) + ) + else: + print( + colorText( + " [βœ—] No policies selected, cannot check preflight status", "red" + ) + ) + + # Final Steps + print( + colorText( + "6. Test ------------------------------------------------------", "cyan" + ) + ) + print( + colorText( + " Prints to console the changes that would be made, must be done to proceed. ", + "cyan", + ) + ) + + print( + colorText( + "7. Liftoff ------------------------------------------------------", "cyan" + ) + ) + print( + colorText( + " Apply path exclusions and approved publishers to selected policy", + "cyan", + ) + ) + print(colorText(" Apply approved hashes to allowlist", "cyan")) + + # Utility Options + print(colorText("F. Open Working Directory", "cyan")) + print(colorText("B. Back", "cyan")) diff --git a/TUI/resultsdisplay.py b/TUI/Widgets/resultsdisplay.py similarity index 74% rename from TUI/resultsdisplay.py rename to TUI/Widgets/resultsdisplay.py index ebabce7..63b204d 100644 --- a/TUI/resultsdisplay.py +++ b/TUI/Widgets/resultsdisplay.py @@ -1,3 +1,18 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + import logging from textual.containers import Horizontal, Vertical @@ -59,15 +74,6 @@ class ResultsDisplay(Widget): margin-top: 1; width: 100%; } - - #button_row { - height: auto; - margin: 1 0 0 0; - } - - #back_button { - width: 1fr; - } """ class CopySuccess(Message): @@ -95,9 +101,9 @@ class ResultsDisplay(Widget): def compose(self): with Vertical(id="results_screen"): - yield Header(show_clock=True, icon="βš™") + yield Header(show_clock=True, icon="βš™οΈ") # Title - title = Static(f"πŸ“Š {self.operation} - Results", id="results_title") + title = Static(f"{self.operation} - Results", id="results_title") yield title # Two-column layout @@ -107,7 +113,7 @@ class ResultsDisplay(Widget): yield Static("βœ… Successful", id="success_label") yield Static(self.successful_results, id="success_results") yield Button( - "πŸ“‹βœ… Copy Success List", + "Copy Success List", id="copy_success", classes="copy_button", ) @@ -117,15 +123,11 @@ class ResultsDisplay(Widget): yield Static("❌ Failed", id="failure_label") yield Static(self.unsuccessful_results, id="failure_results") yield Button( - "πŸ“‹βŒ Copy Failure List", + "Copy Failure List", id="copy_failure", classes="copy_button", ) - # Back Button - with Horizontal(id="button_row"): - back_button = Button("← Back", id="back_button") - yield back_button yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: @@ -138,18 +140,18 @@ class ResultsDisplay(Widget): pyperclip.copy(str(success_widget.renderable)) self.app.notify( - "Òœ… Success list copied to clipboard!", + "Success list copied to clipboard!", severity="information", timeout=2, ) self.post_message(self.CopySuccess()) except ImportError: self.app.notify( - "Òő ï¸ pyperclip not installed. Run: pip install pyperclip", + "❌ pyperclip not installed. Run: pip install pyperclip", severity="warning", ) except Exception as e: - self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") + self.app.notify(f"’ Failed to copy: {str(e)}", severity="error") event.stop() elif btn_id == "copy_failure": @@ -159,20 +161,16 @@ class ResultsDisplay(Widget): pyperclip.copy(str(failure_widget.renderable)) self.app.notify( - "Òœ… Failure list copied to clipboard!", + "Failure list copied to clipboard!", severity="information", timeout=2, ) self.post_message(self.CopyFailure()) except ImportError: self.app.notify( - "Òő ï¸ pyperclip not installed. Run: pip install pyperclip", + "❌ pyperclip not installed. Run: pip install pyperclip", severity="warning", ) except Exception as e: - self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") - event.stop() - - elif btn_id == "back_button": - self.app.pop_screen() + self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") event.stop() diff --git a/TUI/moveagentworkflowscreen.py b/TUI/moveagentworkflowscreen.py deleted file mode 100644 index 8398ddf..0000000 --- a/TUI/moveagentworkflowscreen.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import List, Optional - -from textual.app import ComposeResult -from textual.screen import Screen - -from models.agent import Agent -from TUI.agentmoveoperations import AgentMoveOperations -from TUI.multiagentselector import MultiAgentSelector -from TUI.resultsdisplay import ResultsDisplay - - -class MoveAgentWorkflowScreen(Screen): - """Screen that handles the agent movement workflow.""" - - def __init__(self, all_agents: Optional[List[Agent]]): - super().__init__() - self.all_agents = all_agents - self.selected_agents = None - - def compose(self) -> ComposeResult: - """Start with the multi-agent selector.""" - yield MultiAgentSelector(self.all_agents) - - def on_multi_agent_selector_agents_selected( - self, message: MultiAgentSelector.AgentsSelected - ) -> None: - """Handle selected agents - switch to operations screen.""" - self.selected_agents = message.selected_agents - - # Remove the MultiAgentSelector - selector = self.query_one(MultiAgentSelector) - selector.remove() - - # Mount the AgentMoveOperations with the selected Agent objects - self.mount(AgentMoveOperations(self.selected_agents)) - - def on_agent_move_operations_operation_complete( - self, message: AgentMoveOperations.OperationComplete - ) -> None: - """Handle completion of move operation - transition to results screen.""" - # Format successful results - success_lines = [] - for agent, result in message.successful: - success_lines.append(f"βœ“ {agent.hostname}") - - # Format unsuccessful results - failure_lines = [] - for agent, error in message.unsuccessful: - failure_lines.append(f"βœ— {agent.hostname}: {error}") - - successful_text = "\n".join(success_lines) if success_lines else "(none)" - unsuccessful_text = "\n".join(failure_lines) if failure_lines else "(none)" - - # Remove the operations widget - ops_widget = self.query_one(AgentMoveOperations) - ops_widget.remove() - - # Mount the results display - self.mount( - ResultsDisplay(message.operation, successful_text, unsuccessful_text) - ) diff --git a/TUI/otpworkflowscreen.py b/TUI/otpworkflowscreen.py deleted file mode 100644 index d7d7322..0000000 --- a/TUI/otpworkflowscreen.py +++ /dev/null @@ -1,24 +0,0 @@ -# otp_workflow_screen.py - -from typing import List, Optional - -from textual.app import ComposeResult -from textual.screen import Screen - -from models.agent import Agent -from TUI.OTP_generate import OTPGenerator - - -class OTPWorkflowScreen(Screen): - """Screen that handles the OTP generation workflow without agent selection.""" - - def __init__(self, selected_agents: Optional[List[Agent]]): - super().__init__() - self.selected_agents = selected_agents - - def compose(self) -> ComposeResult: - """Directly show the OTP generator for the selected agents.""" - yield OTPGenerator(self.selected_agents) - - def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: - """Handle OTP generation request - pass it up to the app level if needed.""" diff --git a/flows/localApproval.py b/flows/localApproval.py index f1054f1..d6bda28 100644 --- a/flows/localApproval.py +++ b/flows/localApproval.py @@ -1,6 +1,17 @@ -""" -This module handles the creation of local approval requests. -""" +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . import logging import os @@ -51,7 +62,7 @@ class LocalApprovalRequestor: batch_id = int(time.time()) purpose = ( - f"Γ°ΕΈΕ½Β« Local Approval Γ°ΕΈΕ½Β« - {duration_minutes} mins - " + f" Local Approval - {duration_minutes} mins - " f"batch:{batch_id} Client:{agent_id} User:{self.username}" ) @@ -103,11 +114,9 @@ class LocalApprovalRequestor: success_count = 0 failure_count = 0 - print(colorText(f"\nΓ°ΕΈβ€œΒ¦ Processing batch {batch_id}...", "cyan")) - print(colorText(f"Γ°ΕΈβ€˜Β€ Requested by: {self.username}", "cyan")) - print( - colorText(f"Γ°ΕΈβ€œΕ  Moving {len(agents)} agent(s) to local approval\n", "cyan") - ) + print(colorText(f"\n Processing batch {batch_id}...", "cyan")) + print(colorText(f" Requested by: {self.username}", "cyan")) + print(colorText(f" Moving {len(agents)} agent(s) to local approval\n", "cyan")) for agent in agents: try: @@ -125,11 +134,11 @@ class LocalApprovalRequestor: if not move_success: raise Exception("Failed to move to audit policy") - print(colorText(f"Γ’Ε“β€œ {agent.hostname}", "green")) + print(colorText(f" {agent.hostname}", "green")) success_count += 1 except Exception as e: - print(colorText(f"Γ’Ε“β€” {agent.hostname}: {e}", "red")) + print(colorText(f" {agent.hostname}: {e}", "red")) logger.error(f"Error processing agent {agent.hostname}: {e}") failure_count += 1 @@ -152,7 +161,7 @@ class LocalApprovalRequestor: ] # Display duration options - print(colorText("\nҏ±ï¸ Select Local Approval Duration:", "white")) + print(colorText("\n Select Local Approval Duration:", "white")) print(colorText("=" * 50, "white")) for i, (minutes, label) in enumerate(duration_options, start=1): @@ -166,36 +175,36 @@ class LocalApprovalRequestor: if 1 <= choice <= len(duration_options): duration_minutes, duration_label = duration_options[choice - 1] - print(colorText(f"Γ’Ε“β€œ Selected: {duration_label}", "green")) + print(colorText(f" Selected: {duration_label}", "green")) logger.info(f"User selected duration: {duration_minutes} minutes") else: - print(colorText("ҝŒ Invalid choice.", "red")) + print(colorText("❌ Invalid choice.", "red")) logger.warning("Invalid duration choice") return except ValueError: - print(colorText("ҝŒ Invalid input. Please enter a number.", "red")) + print(colorText("❌ Invalid input. Please enter a number.", "red")) logger.warning("Invalid input for duration selection") return # Select agents - print(colorText("\nΓ°ΕΈΕ½Β― Select Agents for Local Approval:", "white")) + print(colorText("\nSelect Agents for Local Approval:", "white")) agents = selectAgents(self.api) if not agents: - print(colorText("ҝŒ No agents found or error retrieving agents.", "red")) + print(colorText("❌ No agents found or error retrieving agents.", "red")) logger.warning("No agents selected or error retrieving agents") return # Confirm with user - print(colorText("\nΓ°ΕΈβ€œβ€Ή Summary:", "cyan")) + print(colorText("\nSummary:", "cyan")) print(colorText(f" Duration: {duration_label}", "white")) print(colorText(f" Agents: {len(agents)}", "white")) confirm = get_sanitized_input("\nProceed? (y/n): ").lower() if confirm != "y": - print(colorText("ҝŒ Operation cancelled.", "yellow")) + print(colorText("❌ Operation cancelled.", "yellow")) return # Process the batch @@ -219,24 +228,24 @@ class LocalApprovalRequestor: failure_count: Number of failed operations """ print(colorText(f"\n{'=' * 60}", "white")) - print(colorText("Γ°ΕΈβ€œΕ  Local Approval Summary", "cyan")) + print(colorText(" Local Approval Summary", "cyan")) print(colorText("=" * 60, "white")) - print(colorText(f"Γ’Ε“β€œ Successfully processed: {success_count}", "green")) + print(colorText(f" Successfully processed: {success_count}", "green")) if failure_count > 0: - print(colorText(f"Γ’Ε“β€” Failed: {failure_count}", "red")) + print(colorText(f" Failed: {failure_count}", "red")) - print(colorText(f"\nΓ°ΕΈβ€œΒ¦ Batch ID: {batch_id}", "cyan")) - print(colorText(f"ҏ±ï¸ Duration: {duration_label}", "cyan")) + print(colorText(f"\n Batch ID: {batch_id}", "cyan")) + print(colorText(f" Duration: {duration_label}", "cyan")) print(colorText("=" * 60, "white")) - print(colorText("\n💑 Next Steps:", "yellow")) - print(colorText(" Ò€’ Agents have been moved to audit policies", "white")) - print(colorText(" Ò€’ Local approvals are active", "white")) + print(colorText("\n Next Steps:", "yellow")) + print(colorText(" βœ… Agents have been moved to audit policies", "white")) + print(colorText(" βœ… Local approvals are active", "white")) print( colorText( - f" Ò€’ Agents will return to enforcement after {duration_label}", + f" βœ… Agents will return to enforcement after {duration_label}", "white", ) ) diff --git a/flows/prepPolicy.py b/flows/prepPolicy.py index 53f7ede..e6e6298 100644 --- a/flows/prepPolicy.py +++ b/flows/prepPolicy.py @@ -88,7 +88,7 @@ def sortHashes( ): working_dir = load_env("WORKING_DIR") history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1Γ’β‚¬β€œ150): ", + prompt="Enter how many days of history to pull (1-150): ", value_type=int, valid_range=(1, 150), ) @@ -655,7 +655,7 @@ def section_header(title): def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist): working_dir = load_env("WORKING_DIR") - section_header("🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒") + section_header("Prepare to Enforce Policy") print( colorText( "\nSequentially follow these steps to prepare a policy for enforcement:", @@ -670,11 +670,11 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all ) ) if not selected_policies: - print(colorText(" [Γ’Ε“β€”] No policies have been chosen", "red")) + print(colorText(" [❌] No policies have been chosen", "red")) else: print(colorText("The following policies have been chosen:", "green")) for policy in selected_policies: - print(colorText(f" [Γ’Ε“β€œ] {policy.name}", "green")) + print(colorText(f" [βœ…] {policy.name}", "green")) # Step 2: Destination Policy and Allowlist print( @@ -683,22 +683,22 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all if destination_policy: print( colorText( - f" [Γ’Ε“β€œ] {destination_policy[0].name} has been selected as the destination policy", + f" [βœ…] {destination_policy[0].name} has been selected as the destination policy", "green", ) ) else: - print(colorText(" [Γ’Ε“β€”] No destination policy has been chosen", "red")) + print(colorText(" [❌] No destination policy has been chosen", "red")) if destination_allowlist: print( colorText( - f" [Γ’Ε“β€œ] {destination_allowlist[0].name} has been selected as allowlist", + f" [βœ…] {destination_allowlist[0].name} has been selected as allowlist", "green", ) ) else: - print(colorText(" [Γ’Ε“β€”] No allowlist has been chosen", "red")) + print(colorText(" [❌] No allowlist has been chosen", "red")) # Step 3: Data Preparation print( @@ -713,9 +713,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [Γ’Ε“β€œ] Data has been fetched" + " [βœ…] Data has been fetched" if os.path.exists(review_path) - else " [Γ’Ε“β€”] Data has not been fetched" + else " [❌] Data has not been fetched" ), "green" if os.path.exists(review_path) else "red", ) @@ -723,7 +723,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [Γ’Ε“β€”] No policies selected, cannot check data fetch status", "red" + " [❌] No policies selected, cannot check data fetch status", "red" ) ) @@ -756,9 +756,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [Γ’Ε“β€œ] Reviewed hashes have been loaded" + " [βœ…] Reviewed hashes have been loaded" if os.path.exists(approved_path) - else " [Γ’Ε“β€”] Reviewed hashes have not been loaded" + else " [❌] Reviewed hashes have not been loaded" ), "green" if os.path.exists(approved_path) else "red", ) @@ -766,9 +766,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [Γ’Ε“β€œ] Path review list created" + " [βœ…] Path review list created" if os.path.exists(second_review_path) - else " [Γ’Ε“β€”] Path review list has not been created" + else " [❌] Path review list has not been created" ), "green" if os.path.exists(second_review_path) else "red", ) @@ -776,7 +776,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [Γ’Ε“β€”] No policies selected, cannot check reviewed hashes or path list", + " [❌] No policies selected, cannot check reviewed hashes or path list", "red", ) ) @@ -812,9 +812,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [Γ’Ε“β€œ] Reviewed path list detected" + " [βœ…] Reviewed path list detected" if os.path.exists(reviewed_path) - else " [Γ’Ε“β€”] Path review list has not been detected" + else " [❌] Path review list has not been detected" ), "green" if os.path.exists(reviewed_path) else "red", ) @@ -825,9 +825,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [Γ’Ε“β€œ] Preflight Path Exclusion List has been generated" + " [βœ…] Preflight Path Exclusion List has been generated" if preflight_ready - else " [Γ’Ε“β€”] Preflight Path Exclusion List has not been generated" + else " [❌] Preflight Path Exclusion List has not been generated" ), "green" if preflight_ready else "red", ) @@ -835,7 +835,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [Γ’Ε“β€”] No policies selected, cannot check preflight status", "red" + " [❌] No policies selected, cannot check preflight status", "red" ) ) @@ -866,5 +866,5 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print(colorText(" Apply approved hashes to allowlist", "cyan")) # Utility Options - print(colorText("F. Γ°ΕΈβ€œβ€š - Open Working Directory", "cyan")) - print(colorText("B. ðŸ”ő - Back", "cyan")) + print(colorText("F. Open Working Directory", "cyan")) + print(colorText("B. Back", "cyan")) diff --git a/models/execution.py b/models/execution.py index 462193d..439515f 100644 --- a/models/execution.py +++ b/models/execution.py @@ -145,13 +145,13 @@ class Hash: approved_count += 1 except (ValueError, TypeError): logger.debug( - "Needs Review: Scannermatch score is missing or invalid. Ò€” {e}" + "Needs Review: Scannermatch score is missing or invalid. β€” {e}" ) hash_obj.at_decision = "needs_review" needs_review_count += 1 logger.debug( - f"Final counts Ò€” Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}" + f"Final counts β€” Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}" ) return hashes @@ -443,13 +443,13 @@ class ExecutionHistoryRecord: approved_count += 1 except (ValueError, TypeError) as e: logger.debug( - f"Needs Review: Scannermatch score is missing or invalid. Ò€” {e}" + f"Needs Review: Scannermatch score is missing or invalid. β€” {e}" ) hash_obj.at_decision = "needs_review" needs_review_count += 1 logger.debug( - f"Final counts Ò€” Needs Review: {needs_review_count}, " + f"Final counts β€” Needs Review: {needs_review_count}, " f"Approved: {approved_count}, Unapproved: {unapproved_count}" ) diff --git a/services/agenthandler.py b/services/agenthandler.py index 7a788e6..6d6d250 100644 --- a/services/agenthandler.py +++ b/services/agenthandler.py @@ -38,7 +38,7 @@ logger = logging.getLogger(__name__) def devicehistory(api: AirlockAPIWrapper, outputjson: bool): agents = selectAgents(api) history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1Γ’β‚¬β€œ150): ", + prompt="Enter how many days of history to pull (1–150): ", value_type=int, valid_range=(1, 150), ) @@ -60,7 +60,7 @@ def devicehistory(api: AirlockAPIWrapper, outputjson: bool): except Exception as e: print( colorText( - f"ҝŒ Error retrieving history for {agent.hostname}: {e}", "red" + f"❌ Error retrieving history for {agent.hostname}: {e}", "red" ) ) continue @@ -139,7 +139,7 @@ def findAgents(api, return_dataframe): print( colorText( - f"\nÒœ… Matched devices exported to: {working_dir}\\{filename}", + f"\nβœ“ Matched devices exported to: {working_dir}\\{filename}", "green", ) ) @@ -148,7 +148,7 @@ def findAgents(api, return_dataframe): def collect_device_names() -> List[str]: - print(colorText("🔍 Device Search", "cyan")) + print(colorText("πŸ–₯��Â Device Search", "cyan")) print( colorText( "Enter the device hostnames you'd like to search for, one per line.", "cyan" @@ -185,7 +185,7 @@ def collect_device_names() -> List[str]: else: print( colorText( - f"Òő ï¸ Invalid input: '{stripped_line}' Ò€” only letters, numbers, underscores, spaces, and hyphens are allowed.", + f"⚠️ Invalid input: '{stripped_line}' — only letters, numbers, underscores, spaces, and hyphens are allowed.", "yellow", ) ) @@ -235,8 +235,8 @@ def show_unmatched( ] if unmatched: - logger.debug(f"Òő ï¸ No matches for: {', '.join(unmatched)}") - print(colorText(f"Òő ï¸ No matches for: {', '.join(unmatched)}", "yellow")) + logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}") + print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow")) def enrich_agents(agents: List["Agent"], policies: List["Policy"]): @@ -248,7 +248,7 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: device_names = collect_device_names() if not device_names: logger.debug("No device names entered") - print(colorText("Òő ï¸ No device names entered.", "red")) + print(colorText("⚠️ No device names entered.", "red")) return [] use_exact = choose_match_type() @@ -261,11 +261,11 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: show_unmatched(device_names, matched_agents, use_exact) if not matched_agents: - logger.debug("ҝŒ No matching devices found.") - print(colorText("ҝŒ No matching devices found.", "red")) + logger.debug("❌ No matching devices found.") + print(colorText("❌ No matching devices found.", "red")) return [] - print(colorText(f"Òœ… Found {len(matched_agents)} matching device(s).", "green")) + print(colorText(f"βœ“ Found {len(matched_agents)} matching device(s).", "green")) logger.info("Matched agent hostnames:") rows = (len(matched_agents) + 2) // 3 # 3 columns for row in range(rows): @@ -283,8 +283,8 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: ) if not matched_agents: - logger.debug("ҝŒ No matching devices remain after refinement.") - print(colorText("ҝŒ No matching devices remain after refinement.", "red")) + logger.debug("❌ No matching devices remain after refinement.") + print(colorText("❌ No matching devices remain after refinement.", "red")) return [] enrich_agents(matched_agents, policies) @@ -302,7 +302,7 @@ def moveAgentToRelatedPolicy( Args: api: AirlockAPIWrapper instance. agent: Agent object. - policy_relationship_map: Dict mapping enforcement Ò†’ audit. + policy_relationship_map: Dict mapping enforcement Ò–€ –€ℒ audit. mode: 'audit' to move to audit, 'enforcement' to move to enforcement. """ policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") diff --git a/services/policyhandler.py b/services/policyhandler.py index 0fa6a11..e36fe23 100644 --- a/services/policyhandler.py +++ b/services/policyhandler.py @@ -27,9 +27,8 @@ import tqdm from models.policy import Policy from services.API import AirlockAPIWrapper -from utils.configmanager import get_system_json from utils.setup import get_base_directory -from utils.utils import areYouSure, colorText, get_sanitized_input +from utils.utils import colorText logger = logging.getLogger(__name__) @@ -233,17 +232,3 @@ def skipback(days): hex_timestamp = format(timestamp, "08x") objectid_hex = hex_timestamp + "0000000000000000" return ObjectId(objectid_hex) - - -def updateAuditPoliciesFromEnforcementPolices(api: AirlockAPIWrapper): - policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") - for enforcement_policy, audit_policy in policy_relationship_map.items(): - api.policy_clone(enforcement_policy, audit_policy) - api.policy_set_auditmode(audit_policy, "1") - - -def confirmUpdateAfromE(api: AirlockAPIWrapper): - areYouSure() - confirmation = get_sanitized_input("Type 'I AGREE' to continue: ") - if confirmation.strip() == "I AGREE": - updateAuditPoliciesFromEnforcementPolices(api) diff --git a/utils/selector.py b/utils/selector.py index 2cac574..d43f6d8 100644 --- a/utils/selector.py +++ b/utils/selector.py @@ -184,7 +184,7 @@ class Selector: print(colorText(f"βœ… Included {len(selected)} item(s).", "green")) return selected elif mode == "e": - print(colorText(f"🚫 Excluded {len(selected)} item(s).", "yellow")) + print(colorText(f"πŸ‘« Excluded {len(selected)} item(s).", "yellow")) return [item for item in items if item not in selected] else: print(colorText("⚠️ Invalid mode. Returning all items.", "yellow")) @@ -279,7 +279,9 @@ class Selector: df = df[columns] items = [row for _, row in df.iterrows()] - label_func = lambda row: str(row.to_dict()) + + def label_func(row): + return str(row.to_dict()) result = Selector._select_from_list( items, @@ -311,7 +313,9 @@ class Selector: df = df[columns] items = df.to_dict("records") - label_func = lambda row: " | ".join(str(row[col]) for col in df.columns) + + def label_func(row): + return " | ".join(str(row[col]) for col in df.columns) # Show rows first print(colorText(header, "cyan")) @@ -346,7 +350,7 @@ class Selector: print(colorText(f"βœ… Included {len(selected)} row(s).", "green")) return [pd.Series(row) for row in selected] elif mode == "e": - print(colorText(f"🚫 Excluded {len(selected)} row(s).", "yellow")) + print(colorText(f"πŸ‘« Excluded {len(selected)} row(s).", "yellow")) return [pd.Series(row) for row in items if row not in selected] else: print(colorText("⚠️ Invalid mode. Returning no rows.", "yellow")) diff --git a/utils/setup.py b/utils/setup.py index ee4a384..f28a9a3 100644 --- a/utils/setup.py +++ b/utils/setup.py @@ -118,14 +118,14 @@ def setup(): logging.debug(f"{name.capitalize()} directory ensured at: {path}") # Load system config (immutable) - system_config = load_system_config() + load_system_config() # Configure logging with system-defined log level log_level = get_system_value("LOG_LEVEL", str, "INFO") configure_logging(dirs["logs"], log_level) # Load user config (mutable) - user_config = load_user_config(dirs["config"]) + load_user_config(dirs["config"]) # Set up .env file - ONLY for WORKING_DIR (runtime-configurable value) env_path = base_dir / ".env" @@ -155,6 +155,6 @@ def setup(): for subfolder in subfolders: subfolder_path = folder_path / subfolder subfolder_path.mkdir(parents=True, exist_ok=True) - logging.debug(f" └─ '{subfolder}' subfolder created at: {subfolder_path}") + logging.debug(f"'{subfolder}' subfolder created at: {subfolder_path}") logging.info("βœ… Setup complete") -- 2.34.1 From 6ab9de413f6351ad2288849cf092b7a11a255a63 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Wed, 3 Dec 2025 11:45:22 -0500 Subject: [PATCH 09/25] Refactored loxide libs for easier readability --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/lib.rs | 4 +- airlock_libs/src/modules/datatypes.rs | 112 ++++++++++++++++++++ airlock_libs/src/modules/mod.rs | 1 + airlock_libs/src/prelude.rs | 27 +++++ airlock_libs/src/services.rs | 142 +------------------------- requirements.txt | 2 +- 9 files changed, 152 insertions(+), 142 deletions(-) create mode 100644 airlock_libs/src/modules/datatypes.rs create mode 100644 airlock_libs/src/modules/mod.rs create mode 100644 airlock_libs/src/prelude.rs diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 52dd2a2..66728f0 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "5.0.0" +version = "5.0.1" dependencies = [ "chrono", "indicatif", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 07ad133..5704114 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "5.0.0" +version = "5.0.1" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 23e08a3..9ceacbc 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "5.0.0" +version = "5.0.1" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/lib.rs b/airlock_libs/src/lib.rs index d305d0c..b991583 100644 --- a/airlock_libs/src/lib.rs +++ b/airlock_libs/src/lib.rs @@ -1,5 +1,7 @@ use pyo3::prelude::*; -mod services; +pub mod modules; +pub mod services; +pub mod prelude; #[pymodule] fn airlock_libs(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_function(wrap_pyfunction!(services::pull_policy_exec_histories, py)?)?; diff --git a/airlock_libs/src/modules/datatypes.rs b/airlock_libs/src/modules/datatypes.rs new file mode 100644 index 0000000..c27af30 --- /dev/null +++ b/airlock_libs/src/modules/datatypes.rs @@ -0,0 +1,112 @@ +use crate::prelude::*; +use crate::services::get_base_directory; +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct TelemetryConfig { + pub TELEMETRY: bool, + pub TELEM_URL: Option, +} + +impl TelemetryConfig { + pub fn load() -> Self { + let cfg_path = get_base_directory().join("config\\user_config.json"); + if !cfg_path.exists() { + return Self { + TELEMETRY: false, + TELEM_URL: None, + }; + } + match fs::read_to_string(&cfg_path) { + Ok(contents) => serde_json::from_str::(&contents).unwrap_or(Self { + TELEMETRY: false, + TELEM_URL: None, + }), + Err(_) => Self { + TELEMETRY: false, + TELEM_URL: None, + }, + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ApiResponse { + pub(crate) error: String, + pub(crate) response: ExecHistories, +} +#[derive(Debug, Deserialize, Serialize)] +pub struct ExecHistories { + pub(crate) exechistories: Vec, +} +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Group { + pub(crate) checkpoint: String, + #[serde(rename = "type")] + pub(crate) exectype: u8, + pub(crate) username: String, + pub(crate) hostname: String, + pub(crate) netdomain: String, + pub(crate) filename: String, + pub(crate) ppolicy: String, + pub(crate) policyname: String, + pub(crate) policyver: String, + pub(crate) commandline: String, + pub(crate) publisher: String, + pub(crate) pprocess: String, + pub(crate) gprocess: String, + pub(crate) sha256: String, + pub(crate) datetime: String, + pub(crate) md5: String, + pub(crate) sha128: String, + pub(crate) sha384: String, + pub(crate) sha512: String, + pub(crate) ip: String, + pub(crate) localip: String, +} + +pub enum ExtractedValues { + Headers(reqwest::header::HeaderMap), + BaseUrl(String), +} + +pub trait Converter { + fn convert(py: Python<'_>, py_self: &Py, extract_headers: bool) -> ExtractedValues; +} + +pub struct PyData; + +impl Converter for PyData { + fn convert(py: Python<'_>, py_self: &Py, extract_headers: bool) -> ExtractedValues { + if extract_headers { + let headers = py_self.getattr(py, "headers").unwrap().to_string(); + let headers_replace = headers.replace('\'', "\""); + let parsed: Value = serde_json::from_str(headers_replace.as_str()).unwrap(); + let mut header_map = HeaderMap::new(); + if let Some(obj) = parsed.as_object() { + for (_key, value) in obj { + if let Some(v) = value.as_str() { + let val = HeaderValue::from_str(v).unwrap(); + header_map.insert(HeaderName::from_str("X-APIKey").unwrap(), val); + } + } + } + ExtractedValues::Headers(header_map) + } else { + let base_url = py_self.getattr(py, "base_url").unwrap().to_string(); + ExtractedValues::BaseUrl(base_url) + } + } +} + +pub struct SkipBack; + +impl SkipBack { + pub fn find_checkpoint(days: i64) -> ObjectId { + let date_days_ago = Local::now() - Duration::days(days); + let timestamp = date_days_ago.timestamp() as u32; + let mut hex_timestamp = String::new(); + write!(&mut hex_timestamp, "{:08x}", timestamp).unwrap(); + let objectid_hex = format!("{}0000000000000000", hex_timestamp); + ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") + } +} \ No newline at end of file diff --git a/airlock_libs/src/modules/mod.rs b/airlock_libs/src/modules/mod.rs new file mode 100644 index 0000000..58fd615 --- /dev/null +++ b/airlock_libs/src/modules/mod.rs @@ -0,0 +1 @@ +pub mod datatypes; \ No newline at end of file diff --git a/airlock_libs/src/prelude.rs b/airlock_libs/src/prelude.rs new file mode 100644 index 0000000..faa125a --- /dev/null +++ b/airlock_libs/src/prelude.rs @@ -0,0 +1,27 @@ +pub use chrono::{Duration, Local, NaiveDate}; +pub use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; +pub use mongodb::bson::oid::ObjectId; +pub use opentelemetry::global::shutdown_tracer_provider; +pub use opentelemetry::sdk::Resource; +pub use opentelemetry::trace::noop::NoopTracerProvider; +pub use opentelemetry::trace::{Status, TraceContextExt, TraceError}; +pub use opentelemetry::{Context, KeyValue, sdk::trace as sdktrace, trace::Tracer}; +pub use opentelemetry::{Key, global}; +pub use opentelemetry_otlp::WithExportConfig; +pub use pyo3::{prelude::*, types::PyString}; +pub use pyo3_async_runtimes::async_std; +pub use reqwest::{ + Client, + header::{HeaderMap, HeaderName, HeaderValue}, +}; +pub use serde::{Deserialize, Serialize}; +pub use serde_json::Value; +pub use std::{ + collections::HashMap, + env, + fmt::Write, + fs::{self, File}, + io::{Read, Seek, SeekFrom}, + path::PathBuf, + str::FromStr, +}; \ No newline at end of file diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index 14f191d..e38c7e0 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -1,128 +1,5 @@ -use chrono::{Duration, Local, NaiveDate}; -use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; -use mongodb::bson::oid::ObjectId; -use opentelemetry::global::shutdown_tracer_provider; -use opentelemetry::sdk::Resource; -use opentelemetry::trace::noop::NoopTracerProvider; -use opentelemetry::trace::{Status, TraceContextExt, TraceError}; -use opentelemetry::{Context, KeyValue, sdk::trace as sdktrace, trace::Tracer}; -use opentelemetry::{Key, global}; -use opentelemetry_otlp::WithExportConfig; -use pyo3::{prelude::*, types::PyString}; -use pyo3_async_runtimes::async_std; -use reqwest::{ - Client, - header::{HeaderMap, HeaderName, HeaderValue}, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::{ - collections::HashMap, - env, - fmt::Write, - fs::{self, File}, - io::{Read, Seek, SeekFrom}, - path::PathBuf, - str::FromStr, -}; - -#[allow(non_snake_case)] -#[derive(Deserialize, Debug)] -struct TelemetryConfig { - TELEMETRY: bool, - TELEM_URL: Option, -} - -impl TelemetryConfig { - pub fn load() -> Self { - let cfg_path = get_base_directory().join("config\\user_config.json"); - if !cfg_path.exists() { - return Self { - TELEMETRY: false, - TELEM_URL: None, - }; - } - match fs::read_to_string(&cfg_path) { - Ok(contents) => serde_json::from_str::(&contents).unwrap_or(Self { - TELEMETRY: false, - TELEM_URL: None, - }), - Err(_) => Self { - TELEMETRY: false, - TELEM_URL: None, - }, - } - } -} -#[derive(Debug, Deserialize, Serialize)] -struct ApiResponse { - error: String, - response: ExecHistories, -} -#[derive(Debug, Deserialize, Serialize)] -struct ExecHistories { - exechistories: Vec, -} -#[derive(Debug, Deserialize, Serialize, Clone)] -struct Group { - checkpoint: String, - #[serde(rename = "type")] - exectype: u8, - username: String, - hostname: String, - netdomain: String, - filename: String, - ppolicy: String, - policyname: String, - policyver: String, - commandline: String, - publisher: String, - pprocess: String, - gprocess: String, - sha256: String, - datetime: String, - md5: String, - sha128: String, - sha384: String, - sha512: String, - ip: String, - localip: String, -} - -enum ExtractedValues { - Headers(reqwest::header::HeaderMap), - BaseUrl(String), -} - -trait Converter { - fn convert(py: Python<'_>, py_self: &Py, extract_headers: bool) -> ExtractedValues; -} - -struct PyData; - -impl Converter for PyData { - fn convert(py: Python<'_>, py_self: &Py, extract_headers: bool) -> ExtractedValues { - if extract_headers { - let headers = py_self.getattr(py, "headers").unwrap().to_string(); - let headers_replace = headers.replace('\'', "\""); - let parsed: Value = serde_json::from_str(headers_replace.as_str()).unwrap(); - let mut header_map = HeaderMap::new(); - if let Some(obj) = parsed.as_object() { - for (_key, value) in obj { - if let Some(v) = value.as_str() { - let val = HeaderValue::from_str(v).unwrap(); - header_map.insert(HeaderName::from_str("X-APIKey").unwrap(), val); - } - } - } - ExtractedValues::Headers(header_map) - } else { - let base_url = py_self.getattr(py, "base_url").unwrap().to_string(); - ExtractedValues::BaseUrl(base_url) - } - } -} - +use crate::prelude::*; +use crate::modules::datatypes::*; #[pyfunction] pub fn pull_policy_exec_histories( py: Python<'_>, @@ -192,7 +69,7 @@ pub fn pull_policy_exec_histories( std::process::abort(); } } - let mut checkpoint_number: String = skipback(days).to_string(); + let mut checkpoint_number: String = SkipBack::find_checkpoint(days).to_string(); let multi_progress = MultiProgress::new(); multi_progress.set_draw_target(ProgressDrawTarget::stdout()); let progress_bar = multi_progress.add(ProgressBar::new(100)); @@ -410,7 +287,7 @@ async fn history_logging( } } -fn get_base_directory() -> PathBuf { +pub fn get_base_directory() -> PathBuf { let home = env::var_os("HOME") .map(PathBuf::from) .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from)) @@ -427,15 +304,6 @@ fn get_base_directory() -> PathBuf { } } -fn skipback(days: i64) -> ObjectId { - let date_days_ago = Local::now() - Duration::days(days); - let timestamp = date_days_ago.timestamp() as u32; - let mut hex_timestamp = String::new(); - write!(&mut hex_timestamp, "{:08x}", timestamp).unwrap(); - let objectid_hex = format!("{}0000000000000000", hex_timestamp); - ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") -} - fn init_tracer() -> Result, TraceError> { let cfg = TelemetryConfig::load(); if !cfg.TELEMETRY { @@ -457,4 +325,4 @@ fn init_tracer() -> Result, TraceError> { .install_simple() .unwrap(); Ok(Some(tracer)) -} +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 336bdd8..7c0d5ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==5.0.0 \ No newline at end of file +airlock_libs==5.0.1 \ No newline at end of file -- 2.34.1 From 76bd3a6087e0c48cb16678c077fdda8c8c86e6c7 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Wed, 3 Dec 2025 17:54:57 -0500 Subject: [PATCH 10/25] Feature added - MPSC channel closes #38 --- airlock_libs/Cargo.lock | 35 +++++- airlock_libs/Cargo.toml | 3 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/lib.rs | 2 +- airlock_libs/src/modules/datatypes.rs | 2 +- airlock_libs/src/modules/mod.rs | 2 +- airlock_libs/src/prelude.rs | 2 +- airlock_libs/src/services.rs | 153 ++++++++++++-------------- requirements.txt | 2 +- 9 files changed, 115 insertions(+), 88 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 66728f0..ca2d548 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,9 +26,10 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "5.0.1" +version = "5.1.0" dependencies = [ "chrono", + "crossbeam", "indicatif", "mongodb", "opentelemetry 0.18.0", @@ -504,6 +505,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -513,6 +527,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -522,6 +546,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 5704114..36647a0 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "5.0.1" +version = "5.1.0" edition = "2024" [lib] @@ -25,6 +25,7 @@ tracing = "0.1.41" tracing-subscriber = "0.3.20" tracing-opentelemetry = "0.32.0" pyo3-async-runtimes = { version = "0.27.0", features = ["async-std", "tokio"] } +crossbeam = "0.8.4" [package.metadata.maturin] generate-abi-stubs = true diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 9ceacbc..02f03a9 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "5.0.1" +version = "5.1.0" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/lib.rs b/airlock_libs/src/lib.rs index b991583..a3b6e3d 100644 --- a/airlock_libs/src/lib.rs +++ b/airlock_libs/src/lib.rs @@ -1,7 +1,7 @@ use pyo3::prelude::*; pub mod modules; -pub mod services; pub mod prelude; +pub mod services; #[pymodule] fn airlock_libs(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_function(wrap_pyfunction!(services::pull_policy_exec_histories, py)?)?; diff --git a/airlock_libs/src/modules/datatypes.rs b/airlock_libs/src/modules/datatypes.rs index c27af30..5df65f6 100644 --- a/airlock_libs/src/modules/datatypes.rs +++ b/airlock_libs/src/modules/datatypes.rs @@ -109,4 +109,4 @@ impl SkipBack { let objectid_hex = format!("{}0000000000000000", hex_timestamp); ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") } -} \ No newline at end of file +} diff --git a/airlock_libs/src/modules/mod.rs b/airlock_libs/src/modules/mod.rs index 58fd615..0d8681d 100644 --- a/airlock_libs/src/modules/mod.rs +++ b/airlock_libs/src/modules/mod.rs @@ -1 +1 @@ -pub mod datatypes; \ No newline at end of file +pub mod datatypes; diff --git a/airlock_libs/src/prelude.rs b/airlock_libs/src/prelude.rs index faa125a..8d87356 100644 --- a/airlock_libs/src/prelude.rs +++ b/airlock_libs/src/prelude.rs @@ -24,4 +24,4 @@ pub use std::{ io::{Read, Seek, SeekFrom}, path::PathBuf, str::FromStr, -}; \ No newline at end of file +}; diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index e38c7e0..ce95090 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -1,5 +1,9 @@ -use crate::prelude::*; +use std::thread; + +use crossbeam::channel::unbounded; + use crate::modules::datatypes::*; +use crate::prelude::*; #[pyfunction] pub fn pull_policy_exec_histories( py: Python<'_>, @@ -34,7 +38,6 @@ pub fn pull_policy_exec_histories( get_base_directory().display() ) .into(); - let writeable_filepath = file_path.clone(); if !&file_path.exists() { if let Some(parent_dir) = &file_path.parent() && !parent_dir.exists() @@ -61,6 +64,7 @@ pub fn pull_policy_exec_histories( exechistories: vec![], }, }; + let writeable_filepath = file_path.clone(); let data_write = serde_json::to_string_pretty(&data).expect("Failed to serialize"); match fs::write(writeable_filepath.clone(), data_write) { Ok(_) => {} @@ -75,7 +79,7 @@ pub fn pull_policy_exec_histories( let progress_bar = multi_progress.add(ProgressBar::new(100)); progress_bar.set_style( ProgressStyle::default_bar() - .template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len}") + .template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len} {message}") .unwrap(), ); progress_bar.enable_steady_tick(std::time::Duration::from_millis(100)); @@ -108,80 +112,41 @@ pub fn pull_policy_exec_histories( } }); let cutoff = Local::now().naive_local() - Duration::days(days); - let mut f = match File::open(&writeable_filepath) { - Ok(f) => f, - Err(e) => { - println!("Failed to Access {:?}: {}", &writeable_filepath, e); - std::process::abort(); - } - }; - tracer.in_span("Airlock Data Retreival", |cx| { - let span = cx.span(); - span.set_attribute(Key::new("Days").string(days.to_string())); - span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); - loop { - match f.seek(SeekFrom::Start(0)) { - Ok(_) => {} - Err(e) => { - println!("Failed to seek start of {:?}: {}", f, e); - std::process::abort(); - } - } - let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { - let results: ApiResponse = history_logging( - &base_url, - &exec_types, - &checkpoint_number, - &policy_names, - &client, - ); - cx.span().set_attribute(KeyValue::new( - "items_in_response", - results.response.exechistories.len().to_string(), - )); - results - }); - let parsed_responses = execution_histories.response.exechistories; - if parsed_responses.is_empty() { - break; - } - let mut seen: HashMap<(String, String, String), Group> = - if writeable_filepath.exists() { - let mut contents = String::new(); - f.read_to_string(&mut contents).unwrap(); - let existing_data: ApiResponse = - serde_json::from_str(&contents).unwrap_or(ApiResponse { - error: "Success".to_string(), - response: ExecHistories { - exechistories: vec![], - }, - }); - existing_data - .response - .exechistories - .into_iter() - .map(|entry| { - ( - ( - entry.sha256.clone(), - entry.filename.clone(), - entry.hostname.clone(), - ), - entry, - ) - }) - .collect() - } else { - HashMap::new() - }; - for (index, executions) in parsed_responses.iter().enumerate() { + let (tx, rx) = unbounded::>(); + thread::spawn(move || { + let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() + { + let contents = fs::read_to_string(&writeable_filepath).unwrap_or_default(); + let existing: ApiResponse = + serde_json::from_str(&contents).unwrap_or(ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: vec![], + }, + }); + existing + .response + .exechistories + .into_iter() + .map(|entry| { + ( + ( + entry.sha256.clone(), + entry.filename.clone(), + entry.hostname.clone(), + ), + entry, + ) + }) + .collect() + } else { + HashMap::new() + }; + while let Ok(parsed_responses) = rx.recv() { + for executions in parsed_responses { if executions.checkpoint.is_empty() || executions.datetime.is_empty() { continue; } - if index == parsed_responses.len() - 1 { - checkpoint_number = executions.checkpoint.clone(); - break; - } let history_date = match NaiveDate::parse_from_str( &executions.datetime.replace(" +0000 UTC", ""), "%Y-%m-%dT%H:%M:%SZ", @@ -211,7 +176,34 @@ pub fn pull_policy_exec_histories( println!("Failed to write to: {:?}: {}", &writeable_filepath, e); } } - if let Some(last_item) = &final_response.response.exechistories.last() + } + }); + tracer.in_span("Airlock Data Retreival", |cx| { + let span = cx.span(); + span.set_attribute(Key::new("Days").string(days.to_string())); + span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); + loop { + let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { + let results: ApiResponse = history_logging( + &base_url, + &exec_types, + &checkpoint_number, + &policy_names, + &client, + ); + cx.span().set_attribute(KeyValue::new( + "items_in_response", + results.response.exechistories.len().to_string(), + )); + results + }); + let parsed_responses = execution_histories.response.exechistories; + if parsed_responses.is_empty() { + break; + } + tx.send(parsed_responses.clone()).unwrap(); + checkpoint_number = parsed_responses.last().unwrap().checkpoint.clone(); + if let Some(last_item) = parsed_responses.last() && let Ok(last_date) = NaiveDate::parse_from_str( &last_item.datetime.replace(" +0000 UTC", ""), "%Y-%m-%dT%H:%M:%SZ", @@ -219,20 +211,21 @@ pub fn pull_policy_exec_histories( { let date_diff = Local::now().naive_local().date() - last_date; let percentage_diff = - (days - date_diff.num_days()) as f64 / days as f64 * 100.0; - progress_bar.set_position(percentage_diff.round() as u64); + ((days - date_diff.num_days()) as f64 / days as f64 * 100.0).round() as u64; + progress_bar.set_position(percentage_diff); progress_bar.set_message("Total Percent Complete"); } } }); progress_bar.finish_with_message("All Checkpoints Complete"); - let return_data = match fs::read_to_string(&writeable_filepath) { + let return_data = match fs::read_to_string(file_path.clone()) { Ok(return_data) => return_data, Err(e) => { - println!("Failed to read data from: {:?}: {}", &writeable_filepath, e); + println!("Failed to read data from: {:?}: {}", &file_path, e); std::process::abort(); } }; + drop(tx); shutdown_tracer_provider(); return_data.to_string() }); @@ -325,4 +318,4 @@ fn init_tracer() -> Result, TraceError> { .install_simple() .unwrap(); Ok(Some(tracer)) -} \ No newline at end of file +} diff --git a/requirements.txt b/requirements.txt index 7c0d5ff..94ee402 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==5.0.1 \ No newline at end of file +airlock_libs==5.1.0 \ No newline at end of file -- 2.34.1 From b19eeb6c96f330174340bca41599d125db8fef81 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Thu, 4 Dec 2025 11:57:49 -0500 Subject: [PATCH 11/25] Fixed Progress Bar Changed date math so that the last date from the first response from the API is the minimum value for the progress bar. This gives true progress percentages from the first date to the last date on the last request. #36 --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 19 +++++++++++++------ requirements.txt | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index ca2d548..719ddba 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "5.1.0" +version = "5.1.1" dependencies = [ "chrono", "crossbeam", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 36647a0..02c54b3 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "5.1.0" +version = "5.1.1" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 02f03a9..7083989 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "5.1.0" +version = "5.1.1" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index ce95090..aee1edd 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -75,7 +75,7 @@ pub fn pull_policy_exec_histories( } let mut checkpoint_number: String = SkipBack::find_checkpoint(days).to_string(); let multi_progress = MultiProgress::new(); - multi_progress.set_draw_target(ProgressDrawTarget::stdout()); + multi_progress.set_draw_target(ProgressDrawTarget::stderr()); let progress_bar = multi_progress.add(ProgressBar::new(100)); progress_bar.set_style( ProgressStyle::default_bar() @@ -178,6 +178,7 @@ pub fn pull_policy_exec_histories( } } }); + let mut first_date: Option = None; tracer.in_span("Airlock Data Retreival", |cx| { let span = cx.span(); span.set_attribute(Key::new("Days").string(days.to_string())); @@ -209,11 +210,17 @@ pub fn pull_policy_exec_histories( "%Y-%m-%dT%H:%M:%SZ", ) { - let date_diff = Local::now().naive_local().date() - last_date; - let percentage_diff = - ((days - date_diff.num_days()) as f64 / days as f64 * 100.0).round() as u64; - progress_bar.set_position(percentage_diff); - progress_bar.set_message("Total Percent Complete"); + if first_date.is_none() { + first_date = Some(last_date); + } + if let Some(base_date) = first_date { + let date_diff = last_date - base_date; + let total_span = (Local::now().naive_local().date() - base_date).num_days(); + let percentage = ((date_diff.num_days() as f64 / total_span as f64) * 100.0) + .clamp(0.0, 100.0) + .round() as u64; + progress_bar.set_position(percentage); + } } } }); diff --git a/requirements.txt b/requirements.txt index 94ee402..ea2bce2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==5.1.0 \ No newline at end of file +airlock_libs==5.1.1 \ No newline at end of file -- 2.34.1 From 0aabbfd36e4fbcfb24f4eefab39d603c3c56ed98 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 5 Dec 2025 11:12:29 -0500 Subject: [PATCH 12/25] Style Change Cleaned up services.rs file and removed whitespaces Specified Data Types were needed instead of allowing the compiler to select types --- airlock_libs/Cargo.lock | 21 +++++++-- airlock_libs/Cargo.toml | 4 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/modules/datatypes.rs | 2 +- airlock_libs/src/services.rs | 64 +++++++++++++-------------- requirements.txt | 2 +- 6 files changed, 55 insertions(+), 40 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 719ddba..306a6f0 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,11 +26,13 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "5.1.1" +version = "5.1.2" dependencies = [ "chrono", "crossbeam", + "flexi_logger", "indicatif", + "log", "mongodb", "opentelemetry 0.18.0", "opentelemetry-otlp", @@ -799,6 +801,19 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flexi_logger" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e5335674a3a259527f97e9176a3767dcc9b220b8e29d643daeb2d6c72caf8b" +dependencies = [ + "chrono", + "log", + "nu-ansi-term", + "regex", + "thiserror 2.0.17", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1590,9 +1605,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" dependencies = [ "value-bag", ] diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 02c54b3..7be4866 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "5.1.1" +version = "5.1.2" edition = "2024" [lib] @@ -26,6 +26,8 @@ tracing-subscriber = "0.3.20" tracing-opentelemetry = "0.32.0" pyo3-async-runtimes = { version = "0.27.0", features = ["async-std", "tokio"] } crossbeam = "0.8.4" +log = "0.4.29" +flexi_logger = "0.31.7" [package.metadata.maturin] generate-abi-stubs = true diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 7083989..5e959f4 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "5.1.1" +version = "5.1.2" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/modules/datatypes.rs b/airlock_libs/src/modules/datatypes.rs index 5df65f6..c27af30 100644 --- a/airlock_libs/src/modules/datatypes.rs +++ b/airlock_libs/src/modules/datatypes.rs @@ -109,4 +109,4 @@ impl SkipBack { let objectid_hex = format!("{}0000000000000000", hex_timestamp); ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") } -} +} \ No newline at end of file diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index aee1edd..8978738 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -1,7 +1,5 @@ use std::thread; - use crossbeam::channel::unbounded; - use crate::modules::datatypes::*; use crate::prelude::*; #[pyfunction] @@ -16,12 +14,12 @@ pub fn pull_policy_exec_histories( ExtractedValues::Headers(h) => h, ExtractedValues::BaseUrl(_) => std::process::abort(), }; - let base_url = match PyData::convert(py, &py_self, false) { + let base_url: String = match PyData::convert(py, &py_self, false) { ExtractedValues::Headers(_) => std::process::abort(), ExtractedValues::BaseUrl(b) => b, }; - let handle = std::thread::spawn(move || { - let rt = match tokio::runtime::Runtime::new() { + let handle: thread::JoinHandle = std::thread::spawn(move || { + let rt: tokio::runtime::Runtime = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(e) => { println!("Failed to build Tokio Runtime: {:?}", e); @@ -31,8 +29,8 @@ pub fn pull_policy_exec_histories( rt.block_on(async { let _ = init_tracer(); }); - let tracer = global::tracer("global_tracer"); - let _cx = Context::new(); + let tracer: global::BoxedTracer = global::tracer("global_tracer"); + let _cx: Context = Context::new(); let file_path: PathBuf = format!( "{}\\cache\\chunkinator.json", get_base_directory().display() @@ -58,14 +56,14 @@ pub fn pull_policy_exec_histories( } } } - let data = ApiResponse { + let data: ApiResponse = ApiResponse { error: "Success".to_string(), response: ExecHistories { exechistories: vec![], }, }; - let writeable_filepath = file_path.clone(); - let data_write = serde_json::to_string_pretty(&data).expect("Failed to serialize"); + let writeable_filepath: PathBuf = file_path.clone(); + let data_write: String = serde_json::to_string_pretty(&data).expect("Failed to serialize"); match fs::write(writeable_filepath.clone(), data_write) { Ok(_) => {} Err(e) => { @@ -74,17 +72,17 @@ pub fn pull_policy_exec_histories( } } let mut checkpoint_number: String = SkipBack::find_checkpoint(days).to_string(); - let multi_progress = MultiProgress::new(); + let multi_progress: MultiProgress = MultiProgress::new(); multi_progress.set_draw_target(ProgressDrawTarget::stderr()); - let progress_bar = multi_progress.add(ProgressBar::new(100)); + let progress_bar: ProgressBar = multi_progress.add(ProgressBar::new(100)); progress_bar.set_style( ProgressStyle::default_bar() .template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len} {message}") .unwrap(), ); progress_bar.enable_steady_tick(std::time::Duration::from_millis(100)); - let client = tracer.in_span("Building HTTP Client", |cx| { - let client_result = build_client(headers); + let client: Client = tracer.in_span("Building HTTP Client", |cx| { + let client_result: Result = build_client(headers); match client_result { Ok(client_result) => { cx.span().add_event( @@ -111,12 +109,12 @@ pub fn pull_policy_exec_histories( } } }); - let cutoff = Local::now().naive_local() - Duration::days(days); + let cutoff: chrono::NaiveDateTime = Local::now().naive_local() - Duration::days(days); let (tx, rx) = unbounded::>(); thread::spawn(move || { let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() { - let contents = fs::read_to_string(&writeable_filepath).unwrap_or_default(); + let contents: String = fs::read_to_string(&writeable_filepath).unwrap_or_default(); let existing: ApiResponse = serde_json::from_str(&contents).unwrap_or(ApiResponse { error: "Success".to_string(), @@ -128,7 +126,7 @@ pub fn pull_policy_exec_histories( .response .exechistories .into_iter() - .map(|entry| { + .map(|entry: Group| { ( ( entry.sha256.clone(), @@ -147,7 +145,7 @@ pub fn pull_policy_exec_histories( if executions.checkpoint.is_empty() || executions.datetime.is_empty() { continue; } - let history_date = match NaiveDate::parse_from_str( + let history_date: NaiveDate = match NaiveDate::parse_from_str( &executions.datetime.replace(" +0000 UTC", ""), "%Y-%m-%dT%H:%M:%SZ", ) { @@ -155,7 +153,7 @@ pub fn pull_policy_exec_histories( Err(_) => continue, }; if history_date >= cutoff.into() { - let key = ( + let key: (String, String, String) = ( executions.sha256.clone(), executions.filename.clone(), executions.hostname.clone(), @@ -163,13 +161,13 @@ pub fn pull_policy_exec_histories( seen.entry(key).or_insert(executions.clone()); } } - let final_response = ApiResponse { + let final_response: ApiResponse = ApiResponse { error: "Success".to_string(), response: ExecHistories { exechistories: seen.values().cloned().collect(), }, }; - let data_write = serde_json::to_string_pretty(&final_response).unwrap(); + let data_write: String = serde_json::to_string_pretty(&final_response).unwrap(); match fs::write(&writeable_filepath, data_write) { Ok(_) => {} Err(e) => { @@ -180,7 +178,7 @@ pub fn pull_policy_exec_histories( }); let mut first_date: Option = None; tracer.in_span("Airlock Data Retreival", |cx| { - let span = cx.span(); + let span: opentelemetry::trace::SpanRef<'_> = cx.span(); span.set_attribute(Key::new("Days").string(days.to_string())); span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); loop { @@ -198,7 +196,7 @@ pub fn pull_policy_exec_histories( )); results }); - let parsed_responses = execution_histories.response.exechistories; + let parsed_responses: Vec = execution_histories.response.exechistories; if parsed_responses.is_empty() { break; } @@ -214,9 +212,9 @@ pub fn pull_policy_exec_histories( first_date = Some(last_date); } if let Some(base_date) = first_date { - let date_diff = last_date - base_date; - let total_span = (Local::now().naive_local().date() - base_date).num_days(); - let percentage = ((date_diff.num_days() as f64 / total_span as f64) * 100.0) + let date_diff: chrono::TimeDelta = last_date - base_date; + let total_span: i64 = (Local::now().naive_local().date() - base_date).num_days(); + let percentage: u64 = ((date_diff.num_days() as f64 / total_span as f64) * 100.0) .clamp(0.0, 100.0) .round() as u64; progress_bar.set_position(percentage); @@ -225,7 +223,7 @@ pub fn pull_policy_exec_histories( } }); progress_bar.finish_with_message("All Checkpoints Complete"); - let return_data = match fs::read_to_string(file_path.clone()) { + let return_data: String = match fs::read_to_string(file_path.clone()) { Ok(return_data) => return_data, Err(e) => { println!("Failed to read data from: {:?}: {}", &file_path, e); @@ -236,8 +234,8 @@ pub fn pull_policy_exec_histories( shutdown_tracer_provider(); return_data.to_string() }); - let gil_value = handle.join().unwrap(); - Python::attach(|py| PyString::new(py, &gil_value).into()) + let gil_value: String = handle.join().unwrap(); + Python::attach(|py: Python<'_>| PyString::new(py, &gil_value).into()) } fn build_client(headers: HeaderMap) -> Result { @@ -264,7 +262,7 @@ async fn history_logging( }}"#, exec_types, checkpoint_number, policy_names ); - let res = client + let res: Result = client .post(format!("{}/v1/logging/exechistories", base_url)) .body(payload) .send() @@ -305,13 +303,13 @@ pub fn get_base_directory() -> PathBuf { } fn init_tracer() -> Result, TraceError> { - let cfg = TelemetryConfig::load(); + let cfg: TelemetryConfig = TelemetryConfig::load(); if !cfg.TELEMETRY { global::set_tracer_provider(NoopTracerProvider::new()); return Ok(None); } - let endpoint = cfg.TELEM_URL.unwrap_or_default(); - let tracer = + let endpoint: String = cfg.TELEM_URL.unwrap_or_default(); + let tracer: sdktrace::Tracer = opentelemetry_otlp::new_pipeline() .tracing() .with_exporter( diff --git a/requirements.txt b/requirements.txt index ea2bce2..ada2a2d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==5.1.1 \ No newline at end of file +airlock_libs==5.1.2 \ No newline at end of file -- 2.34.1 From 98cb23e5eacc4ddf291a19e5fbb72f5a638a4bcd Mon Sep 17 00:00:00 2001 From: Zarithas Date: Fri, 5 Dec 2025 14:48:23 -0500 Subject: [PATCH 13/25] Bugfix for Issue 39. Fixes: https://git.racooncity.org/brotoskyj/AirlockTools/issues/39 --- TUI/Screens/quietagentworkflowscreen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TUI/Screens/quietagentworkflowscreen.py b/TUI/Screens/quietagentworkflowscreen.py index f0d5bbb..e3c9c04 100644 --- a/TUI/Screens/quietagentworkflowscreen.py +++ b/TUI/Screens/quietagentworkflowscreen.py @@ -394,7 +394,7 @@ class QuietAgentWorkflowScreen(Screen): # Categorize agents into DataFrames self.enforce_ready_df = agents[agents["enforce_ready"]].copy() - self.non_enforce_ready_df = agents[not agents["enforce_ready"]].copy() + self.non_enforce_ready_df = agents[~agents["enforce_ready"]].copy() logger.info( f"Analysis complete: {len(self.enforce_ready_df)} enforce ready, " -- 2.34.1 From 3ab803c12e30d129d3c604779c848572f3950949 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Fri, 5 Dec 2025 15:05:46 -0500 Subject: [PATCH 14/25] Quiet Agent UI improvements --- TUI/Screens/quietagentworkflowscreen.py | 293 +++++++++++++++--------- 1 file changed, 183 insertions(+), 110 deletions(-) diff --git a/TUI/Screens/quietagentworkflowscreen.py b/TUI/Screens/quietagentworkflowscreen.py index f0d5bbb..1532b1d 100644 --- a/TUI/Screens/quietagentworkflowscreen.py +++ b/TUI/Screens/quietagentworkflowscreen.py @@ -34,7 +34,7 @@ from textual.app import ComposeResult from textual.containers import Horizontal, Vertical from textual.reactive import reactive from textual.screen import Screen -from textual.widgets import Button, DataTable, Footer, Header, Static +from textual.widgets import Button, DataTable, Footer, Header, Input, Static from models.policy import Policy from services.API import AirlockAPIWrapper @@ -51,16 +51,17 @@ class QuietAgentWorkflowScreen(Screen): This screen provides a multi-step workflow: 1. Select initial policy to analyze - 2. View categorized agents (enforce ready vs. non-enforce ready) - 3. Select target policies for each category - 4. Execute agent migrations + 2. Configure analysis parameters (history period and quiet time period) + 3. View categorized agents (enforce ready vs. non-enforce ready) + 4. Select target policies for each category + 5. Execute agent migrations Attributes: api (AirlockAPIWrapper): API wrapper for Airlock operations policies (List[Policy]): List of all available policies selected_policy (Optional[Policy]): The initially selected policy to analyze - history_days (int): Number of days of history to pull (default: 150) - quiet_days (int): Number of days without execution to be considered quiet (default: 45) + history_days (int): Number of days of history to pull (default: 150, range: 1-365) + quiet_days (int): Number of days without execution to be considered quiet (default: 45, range: 1-365) agents_df (Optional[pd.DataFrame]): DataFrame of all agents with analysis results enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents ready for enforcement non_enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents not ready for enforcement @@ -86,7 +87,7 @@ class QuietAgentWorkflowScreen(Screen): self.api = api self.policies = policies self.selected_policy: Optional[Policy] = None - self.history_days = 150 # Fixed as per requirements + self.history_days = 150 # Default value, user-selectable self.quiet_days = 45 # Default value self.agents_df: Optional[pd.DataFrame] = None self.enforce_ready_df: Optional[pd.DataFrame] = None @@ -130,7 +131,7 @@ class QuietAgentWorkflowScreen(Screen): stage_messages = { "select_policy": "Step 1: Select Policy to Analyze", - "select_quiet_days": "Step 2: Select Quiet Time Period", + "select_history_days": "Step 2: Configure Analysis Parameters", "analyzing": "Analyzing agent activity...", "view_results": "Step 3: Review Categorized Agents", "select_enforce_target": "Step 4: Select Target Policy for Enforce Ready Agents", @@ -161,7 +162,7 @@ class QuietAgentWorkflowScreen(Screen): # Initial policy selection for analysis self.selected_policy = message.policy logger.info(f"Selected policy for analysis: {self.selected_policy.name}") - self._show_quiet_days_selection() + self._show_history_days_selection() elif self.workflow_stage == "select_enforce_target": # Target policy selection for enforce ready agents self.enforce_ready_target_policy = message.policy @@ -177,48 +178,167 @@ class QuietAgentWorkflowScreen(Screen): ) self._show_migration_confirmation() - def _show_quiet_days_selection(self) -> None: - """Show the quiet days selection screen.""" - self.workflow_stage = "select_quiet_days" + def _show_history_days_selection(self) -> None: + """Show the history days and quiet days selection screen.""" + self.workflow_stage = "select_history_days" content = self.query_one("#content_area", Vertical) content.remove_children() # Create info text info_widget = Static( f"Policy Selected: {self.selected_policy.name}\n\n" - f"History Period: {self.history_days} days\n\n" - "Select quiet time period (days without untrusted execution):", - id="quiet_days_info", + "Configure Analysis Parameters:", + id="analysis_params_info", ) info_widget.styles.margin = (0, 0, 2, 0) content.mount(info_widget) - # Create button container and mount it first - button_container = Vertical(id="quiet_days_buttons") - button_container.styles.height = "auto" - content.mount(button_container) + # Create input container + input_container = Vertical(id="analysis_params_input_container") + input_container.styles.height = "auto" + content.mount(input_container) - # Now add buttons to the mounted container - for days in [15, 30, 45, 60]: - btn = Button( - f"{days} days {'(Default)' if days == 45 else ''}", - id=f"quiet_days_{days}", - classes="quiet_day_btn", + # History days label + history_label = Static("History Period (days of execution history to pull):") + history_label.styles.margin = (0, 0, 1, 0) + input_container.mount(history_label) + + # Add history days input field + history_input = Input( + placeholder="Enter days (1-365, default: 150)", + value="150", + id="history_days_input", + ) + history_input.styles.width = "50" + history_input.styles.margin = (0, 0, 2, 0) + input_container.mount(history_input) + + # Quiet days label + quiet_label = Static( + "Quiet Time Period (days without execution to be considered quiet):" + ) + quiet_label.styles.margin = (0, 0, 1, 0) + input_container.mount(quiet_label) + + # Add quiet days input field + quiet_input = Input( + placeholder="Enter days (1-365, default: 45)", + value="45", + id="quiet_days_input", + ) + quiet_input.styles.width = "50" + quiet_input.styles.margin = (0, 0, 2, 0) + input_container.mount(quiet_input) + + # Add submit button + submit_btn = Button( + "Continue", + id="analysis_params_submit", + variant="primary", + ) + submit_btn.styles.width = "50" + submit_btn.styles.margin = (1, 0, 0, 0) + input_container.mount(submit_btn) + + # Focus the first input field + history_input.focus() + + def _validate_and_submit_history_days(self) -> None: + """Validate and submit the history days and quiet days inputs.""" + try: + history_input = self.query_one("#history_days_input", Input) + quiet_input = self.query_one("#quiet_days_input", Input) + + history_value = history_input.value.strip() + quiet_value = quiet_input.value.strip() + + # Validate history days + if not history_value: + self.app.notify( + "Please enter a history period value", severity="error", timeout=3 + ) + history_input.focus() + return + + try: + history_days = int(history_value) + except ValueError: + self.app.notify( + "Please enter a valid number for history period", + severity="error", + timeout=3, + ) + history_input.focus() + return + + if history_days < 1 or history_days > 365: + self.app.notify( + "History period must be between 1 and 365 days", + severity="error", + timeout=3, + ) + history_input.focus() + return + + # Validate quiet days + if not quiet_value: + self.app.notify( + "Please enter a quiet time period value", + severity="error", + timeout=3, + ) + quiet_input.focus() + return + + try: + quiet_days = int(quiet_value) + except ValueError: + self.app.notify( + "Please enter a valid number for quiet time period", + severity="error", + timeout=3, + ) + quiet_input.focus() + return + + if quiet_days < 1 or quiet_days > 365: + self.app.notify( + "Quiet time period must be between 1 and 365 days", + severity="error", + timeout=3, + ) + quiet_input.focus() + return + + # Check that quiet days doesn't exceed history days + if quiet_days > history_days: + self.app.notify( + "Quiet time period cannot exceed history period", + severity="error", + timeout=3, + ) + quiet_input.focus() + return + + # All validation passed + self.history_days = history_days + self.quiet_days = quiet_days + logger.info( + f"Selected history days: {history_days}, quiet days: {quiet_days}" ) - btn.styles.width = "100%" - btn.styles.margin = (0, 0, 1, 0) - button_container.mount(btn) + self._start_analysis() + + except Exception as e: + logger.error(f"Error validating analysis parameters: {e}") + self.app.notify(f"Error: {str(e)}", severity="error", timeout=3) def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button press events.""" button_id = event.button.id - # Quiet days selection buttons - if button_id and button_id.startswith("quiet_days_"): - days = int(button_id.split("_")[-1]) - self.quiet_days = days - logger.info(f"Selected quiet days: {days}") - self._start_analysis() + # Analysis parameters submit button + if button_id == "analysis_params_submit": + self._validate_and_submit_history_days() return # Navigation buttons @@ -258,46 +378,44 @@ class QuietAgentWorkflowScreen(Screen): self._show_policy_selection() return + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle input submission (Enter key pressed).""" + if event.input.id in ["history_days_input", "quiet_days_input"]: + self._validate_and_submit_history_days() + def _start_analysis(self) -> None: """Start the agent activity analysis.""" - self.workflow_stage = "analyzing" - content = self.query_one("#content_area", Vertical) - content.remove_children() - - # Show analyzing message with detailed steps - analyzing_msg = Static( - f"Analyzing Agent Activity\n" - f"{'=' * 50}\n\n" - f"Policy: {self.selected_policy.name}\n" - f"History Period: {self.history_days} days\n" - f"Quiet Threshold: {self.quiet_days} days\n\n" - f"Progress:\n" - f"Step 1/4: Fetching agents from policy...\n" - f"Step 2/4: Pulling execution history (this may take a moment)...\n" - f"Step 3/4: Analyzing activity patterns...\n" - f"Step 4/4: Categorizing agents...\n\n" - f"Please wait - this operation cannot be cancelled.", - id="analyzing_message", - ) - analyzing_msg.styles.margin = (2, 1) - content.mount(analyzing_msg) - - # Show notification + # Show notification that analysis is starting self.app.notify( "Starting analysis - this may take several minutes for large policies", severity="information", timeout=5, ) - # Perform the analysis asynchronously - self.call_later(self._perform_analysis) + # Clear the screen to provide a blank canvas for Rust progress output + # (Rust output displays over the TUI, so we clear everything except header/footer) + try: + # Clear title + title_widget = self.query_one("#workflow_title", Static) + title_widget.update("") - def _perform_analysis(self) -> None: + # Clear status + status_widget = self.query_one("#workflow_status", Static) + status_widget.update("") + + # Clear content area + content = self.query_one("#content_area", Vertical) + content.remove_children() + except Exception as e: + logger.debug(f"Could not clear screen for analysis: {e}") + + # Delay the analysis start to ensure UI refresh completes first + # This prevents Rust output from starting before the screen is cleared + self.set_timer(0.2, self._perform_analysis_worker) + + def _perform_analysis_worker(self) -> None: """Perform the actual agent activity analysis.""" try: - # Update status: Fetching agents - self._update_analysis_status("Step 1/4: Fetching agents from policy...") - # Get agents in the selected policy agents = self.api.agents_find_by_group(self.selected_policy.groupid) @@ -310,32 +428,11 @@ class QuietAgentWorkflowScreen(Screen): self._show_policy_selection() return - agent_count = len(agents) - self.app.notify( - f"Found {agent_count} agents - fetching execution history...", - severity="information", - timeout=3, - ) - - # Update status: Pulling execution history - self._update_analysis_status( - f"Step 2/4: Pulling execution history for {agent_count} agents...\n" - f"(This may take several minutes - progress shown in terminal)" - ) - # Get execution history (this shows progress bars in terminal via airlock_libs) policy_exec_history = getPolicyInfo( self.api, self.selected_policy, [1, 2, 6, 7], self.history_days ) - # Update status: Analyzing patterns - self._update_analysis_status("Step 3/4: Analyzing activity patterns...") - self.app.notify( - "History retrieved - analyzing patterns...", - severity="information", - timeout=2, - ) - if policy_exec_history.empty: logger.info( "No execution history found for the selected policy and time range." @@ -381,9 +478,6 @@ class QuietAgentWorkflowScreen(Screen): lambda x: True if pd.isna(x) or x > self.quiet_days else False ) - # Update status: Categorizing - self._update_analysis_status("Step 4/4: Categorizing agents...") - # Sort agents agents = agents.sort_values( by=["execution_count", "hostname"], ascending=[True, True] @@ -394,7 +488,7 @@ class QuietAgentWorkflowScreen(Screen): # Categorize agents into DataFrames self.enforce_ready_df = agents[agents["enforce_ready"]].copy() - self.non_enforce_ready_df = agents[not agents["enforce_ready"]].copy() + self.non_enforce_ready_df = agents[~agents["enforce_ready"]].copy() logger.info( f"Analysis complete: {len(self.enforce_ready_df)} enforce ready, " @@ -416,27 +510,6 @@ class QuietAgentWorkflowScreen(Screen): self.app.notify(f"Analysis failed: {str(e)}", severity="error", timeout=5) self._show_policy_selection() - def _update_analysis_status(self, status_text: str) -> None: - """Update the analysis status message.""" - try: - analyzing_msg = self.query_one("#analyzing_message", Static) - - # Build updated message - updated_text = ( - f"Analyzing Agent Activity\n" - f"{'=' * 50}\n\n" - f"Policy: {self.selected_policy.name}\n" - f"History Period: {self.history_days} days\n" - f"Quiet Threshold: {self.quiet_days} days\n\n" - f"Progress:\n" - f"{status_text}\n\n" - f"Please wait - this operation cannot be cancelled." - ) - - analyzing_msg.update(updated_text) - except Exception as e: - logger.debug(f"Could not update analysis status: {e}") - def _show_results(self) -> None: """Show the categorized results.""" self.workflow_stage = "view_results" @@ -821,7 +894,7 @@ class QuietAgentWorkflowScreen(Screen): # Depending on stage, go back to previous stage or exit if self.workflow_stage in ["select_policy", "view_results", "complete"]: self.app.pop_screen() - elif self.workflow_stage == "select_quiet_days": + elif self.workflow_stage == "select_history_days": self._show_policy_selection() elif self.workflow_stage == "select_enforce_target": self._show_results() -- 2.34.1 From 154a7efcc818111f4a8fbfa0e367a18f00787ee8 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Fri, 5 Dec 2025 15:30:24 -0500 Subject: [PATCH 15/25] Bug fix for Revoke OTP resolved, no longer crashes when select all is chosen when there are no active sessions, Swapped Revoke OTP and Quiet Hosts locations on menus --- TUI/Screens/otprevokescreen.py | 61 ++++++++++++------------- TUI/Screens/quietagentworkflowscreen.py | 2 +- TUI/TUI.py | 8 ++-- 3 files changed, 35 insertions(+), 36 deletions(-) diff --git a/TUI/Screens/otprevokescreen.py b/TUI/Screens/otprevokescreen.py index 5dc142d..93c7a23 100644 --- a/TUI/Screens/otprevokescreen.py +++ b/TUI/Screens/otprevokescreen.py @@ -58,9 +58,14 @@ class OTPRevokeWidget(Static): } #button_container { height: auto; + width: 100%; padding: 1; align: center middle; } + #button_container Button { + min-width: 16; + margin: 0 1; + } #result_container { height: auto; max-height: 10; @@ -87,29 +92,10 @@ class OTPRevokeWidget(Static): # Action buttons with Horizontal(id="button_container"): - self.refresh_button = Button("πŸ”„ Refresh", id="refresh_btn") - self.refresh_button.styles.width = "15%" - self.refresh_button.styles.margin = (1, 1, 1, 1) - yield self.refresh_button - - self.select_all_button = Button("β˜‘οΈ Select All", id="select_all_btn") - self.select_all_button.styles.width = "15%" - self.select_all_button.styles.margin = (1, 1, 1, 1) - yield self.select_all_button - - self.select_none_button = Button( - "❌ Clear Selection", id="select_none_btn" - ) - self.select_none_button.styles.width = "20%" - self.select_none_button.styles.margin = (1, 1, 1, 1) - yield self.select_none_button - - self.revoke_button = Button( - "πŸ›‘ Revoke Selected", id="revoke_btn", variant="error" - ) - self.revoke_button.styles.width = "20%" - self.revoke_button.styles.margin = (1, 1, 1, 1) - yield self.revoke_button + yield Button("Refresh", id="refresh_btn") + yield Button("Select All", id="select_all_btn") + yield Button("Clear Selection", id="select_none_btn") + yield Button("Revoke Selected", id="revoke_btn", variant="error") # Results display with Vertical(id="result_container"): @@ -122,7 +108,7 @@ class OTPRevokeWidget(Static): # Configure sessions table self.sessions_table.clear() self.sessions_table.add_columns( - "☐", "OTP ID", "Hostname", "Status", "Purpose", "Granted" + "", "OTP ID", "Hostname", "Status", "Purpose", "Granted" ) # Enable row selection with checkbox column @@ -213,7 +199,11 @@ class OTPRevokeWidget(Static): elif btn.id == "select_all_btn": # Select all visible rows - if self._filtered_df is not None: + if ( + self._filtered_df is not None + and not self._filtered_df.empty + and "otpid" in self._filtered_df.columns + ): self._selected_otpids = set(str(x) for x in self._filtered_df["otpid"]) await self._refresh_table() @@ -235,7 +225,12 @@ class OTPRevokeWidget(Static): # Get the row index from the cursor row row_index = self.sessions_table.cursor_row - if self._filtered_df is not None and row_index < len(self._filtered_df): + if ( + self._filtered_df is not None + and not self._filtered_df.empty + and "otpid" in self._filtered_df.columns + and row_index < len(self._filtered_df) + ): # Get the OTP ID for this row otpid = str(self._filtered_df.iloc[row_index]["otpid"]) @@ -257,12 +252,12 @@ class OTPRevokeWidget(Static): async def _revoke_selected(self) -> None: """Revoke the selected OTP sessions.""" if not self._selected_otpids: - self.results_display.update("❌ No sessions selected for revocation") + self.results_display.update("No sessions selected for revocation") return api = getattr(self.app, "api", None) if not api: - self.results_display.update("❌ API not available") + self.results_display.update("API not available") return # Collect results @@ -303,13 +298,13 @@ class OTPRevokeWidget(Static): else "No response" ) results.append( - f"❌ Failed to revoke OTP {otpid} for {hostname}: {error_msg}" + f"Failed to revoke OTP {otpid} for {hostname}: {error_msg}" ) logger.error(f"Failed to revoke OTP {otpid}: {error_msg}") except Exception as e: failure_count += 1 - results.append(f"❌ Error revoking OTP {otpid}: {str(e)}") + results.append(f"Error revoking OTP {otpid}: {str(e)}") logger.exception(f"Exception revoking OTP {otpid}: {e}") # Update results display @@ -368,7 +363,11 @@ class OTPRevokeScreen(Screen): async def action_select_all(self) -> None: """Select all visible sessions.""" - if self.widget._filtered_df is not None: + if ( + self.widget._filtered_df is not None + and not self.widget._filtered_df.empty + and "otpid" in self.widget._filtered_df.columns + ): self.widget._selected_otpids = set( str(x) for x in self.widget._filtered_df["otpid"] ) diff --git a/TUI/Screens/quietagentworkflowscreen.py b/TUI/Screens/quietagentworkflowscreen.py index 1532b1d..bca4b31 100644 --- a/TUI/Screens/quietagentworkflowscreen.py +++ b/TUI/Screens/quietagentworkflowscreen.py @@ -411,7 +411,7 @@ class QuietAgentWorkflowScreen(Screen): # Delay the analysis start to ensure UI refresh completes first # This prevents Rust output from starting before the screen is cleared - self.set_timer(0.2, self._perform_analysis_worker) + self.set_timer(0.5, self._perform_analysis_worker) def _perform_analysis_worker(self) -> None: """Perform the actual agent activity analysis.""" diff --git a/TUI/TUI.py b/TUI/TUI.py index 1a8ce68..1a6f603 100644 --- a/TUI/TUI.py +++ b/TUI/TUI.py @@ -92,15 +92,15 @@ class MainMenuScreen(Screen): BUTTON_DEFS = { "agent_actions": [ ( - "πŸ–₯️ - Find, Move, or Generate OTP for Agents", + "πŸ–₯️ - Find agent, Move agent, or Generate One Time Pass", "move_agent_workflow_button", ), - ("🎫 - Review and appove OTP Activities", "otp_activities_button"), - ("πŸ”• - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), + ("🎫 - Review and approve OTP Activities", "otp_activities_button"), + ("πŸ›‘ - Revoke Active OTP Session", "otp_revoke_button"), ], "policy": [ ("βš–οΈ - Prepare Policy For Enforcement", "policy_prep_button"), - ("πŸ›‘ - Revoke OTPs", "otp_revoke_button"), + ("πŸ”• - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), ], } -- 2.34.1 From 0ac3b54d8993e4397a77616064b80e9cecdc47a2 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Fri, 5 Dec 2025 17:19:40 -0500 Subject: [PATCH 16/25] Refactored Progress Bar Refactored Progress Bar to remove multiprogress bar and only draw one instance. #36 is still open and not fixed with this push, but I believe this is the way to fix the issue. Also implemented an Arc Mutex on the progress bar so it can be controlled via different threads. --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/modules/datatypes.rs | 2 +- airlock_libs/src/services.rs | 34 ++++++++++++++++++--------- requirements.txt | 2 +- 6 files changed, 28 insertions(+), 16 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 306a6f0..64e4271 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "5.1.2" +version = "5.2.0" dependencies = [ "chrono", "crossbeam", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 7be4866..211f014 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "5.1.2" +version = "5.2.0" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 5e959f4..9c11f5e 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "5.1.2" +version = "5.2.0" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/modules/datatypes.rs b/airlock_libs/src/modules/datatypes.rs index c27af30..5df65f6 100644 --- a/airlock_libs/src/modules/datatypes.rs +++ b/airlock_libs/src/modules/datatypes.rs @@ -109,4 +109,4 @@ impl SkipBack { let objectid_hex = format!("{}0000000000000000", hex_timestamp); ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") } -} \ No newline at end of file +} diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index 8978738..a0362f9 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -1,7 +1,8 @@ -use std::thread; -use crossbeam::channel::unbounded; use crate::modules::datatypes::*; use crate::prelude::*; +use crossbeam::channel::unbounded; +use std::sync::{Arc, Mutex}; +use std::thread; #[pyfunction] pub fn pull_policy_exec_histories( py: Python<'_>, @@ -72,15 +73,16 @@ pub fn pull_policy_exec_histories( } } let mut checkpoint_number: String = SkipBack::find_checkpoint(days).to_string(); - let multi_progress: MultiProgress = MultiProgress::new(); - multi_progress.set_draw_target(ProgressDrawTarget::stderr()); - let progress_bar: ProgressBar = multi_progress.add(ProgressBar::new(100)); - progress_bar.set_style( + let progress_bar = Arc::new(Mutex::new(ProgressBar::new(100))); + progress_bar + .lock() + .unwrap() + .set_draw_target(ProgressDrawTarget::stderr()); + progress_bar.lock().unwrap().set_style( ProgressStyle::default_bar() .template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len} {message}") .unwrap(), ); - progress_bar.enable_steady_tick(std::time::Duration::from_millis(100)); let client: Client = tracer.in_span("Building HTTP Client", |cx| { let client_result: Result = build_client(headers); match client_result { @@ -111,6 +113,7 @@ pub fn pull_policy_exec_histories( }); let cutoff: chrono::NaiveDateTime = Local::now().naive_local() - Duration::days(days); let (tx, rx) = unbounded::>(); + let pb_clone = progress_bar.clone(); thread::spawn(move || { let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() { @@ -178,6 +181,10 @@ pub fn pull_policy_exec_histories( }); let mut first_date: Option = None; tracer.in_span("Airlock Data Retreival", |cx| { + pb_clone + .lock() + .unwrap() + .enable_steady_tick(std::time::Duration::from_millis(100)); let span: opentelemetry::trace::SpanRef<'_> = cx.span(); span.set_attribute(Key::new("Days").string(days.to_string())); span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); @@ -213,16 +220,21 @@ pub fn pull_policy_exec_histories( } if let Some(base_date) = first_date { let date_diff: chrono::TimeDelta = last_date - base_date; - let total_span: i64 = (Local::now().naive_local().date() - base_date).num_days(); - let percentage: u64 = ((date_diff.num_days() as f64 / total_span as f64) * 100.0) + let total_span: i64 = + (Local::now().naive_local().date() - base_date).num_days(); + let percentage: u64 = ((date_diff.num_days() as f64 / total_span as f64) + * 100.0) .clamp(0.0, 100.0) .round() as u64; - progress_bar.set_position(percentage); + pb_clone.lock().unwrap().set_position(percentage); } } } }); - progress_bar.finish_with_message("All Checkpoints Complete"); + progress_bar + .lock() + .unwrap() + .finish_with_message("All Checkpoints Complete"); let return_data: String = match fs::read_to_string(file_path.clone()) { Ok(return_data) => return_data, Err(e) => { diff --git a/requirements.txt b/requirements.txt index ada2a2d..b11687f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==5.1.2 \ No newline at end of file +airlock_libs==5.2.0 \ No newline at end of file -- 2.34.1 From 59bb97ec4e871403a8e55d32ede24b0460e717b5 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Thu, 11 Dec 2025 11:47:50 -0500 Subject: [PATCH 17/25] Refactored LoxideLibs 1. Added compatibility check, LoxideLibs will now abort the entire program if OS is not linux or windows. 2. Changed the python data extraction compatibility layer, LoxideLibs was calling the extract data function twice, causing very slight overhead. I have now changed this so that the function returns a Struct that is now easily extractable via dot method notation. --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/modules/datatypes.rs | 46 ++++++++++++--------------- airlock_libs/src/services.rs | 17 +++++----- requirements.txt | 2 +- 6 files changed, 32 insertions(+), 39 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 64e4271..e02d86e 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "5.2.0" +version = "5.2.1" dependencies = [ "chrono", "crossbeam", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 211f014..040ecf4 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "5.2.0" +version = "5.2.1" edition = "2024" [lib] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 9c11f5e..2aa44c8 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "5.2.0" +version = "5.2.1" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/modules/datatypes.rs b/airlock_libs/src/modules/datatypes.rs index 5df65f6..9b4b351 100644 --- a/airlock_libs/src/modules/datatypes.rs +++ b/airlock_libs/src/modules/datatypes.rs @@ -64,36 +64,30 @@ pub struct Group { pub(crate) localip: String, } -pub enum ExtractedValues { - Headers(reqwest::header::HeaderMap), - BaseUrl(String), +pub struct PyData { + pub headers: reqwest::header::HeaderMap, + pub base_url: String, } -pub trait Converter { - fn convert(py: Python<'_>, py_self: &Py, extract_headers: bool) -> ExtractedValues; -} - -pub struct PyData; - -impl Converter for PyData { - fn convert(py: Python<'_>, py_self: &Py, extract_headers: bool) -> ExtractedValues { - if extract_headers { - let headers = py_self.getattr(py, "headers").unwrap().to_string(); - let headers_replace = headers.replace('\'', "\""); - let parsed: Value = serde_json::from_str(headers_replace.as_str()).unwrap(); - let mut header_map = HeaderMap::new(); - if let Some(obj) = parsed.as_object() { - for (_key, value) in obj { - if let Some(v) = value.as_str() { - let val = HeaderValue::from_str(v).unwrap(); - header_map.insert(HeaderName::from_str("X-APIKey").unwrap(), val); - } +impl PyData { + pub fn extract_data(py: Python<'_>, obj: &Py) -> Self { + let headers_raw = obj.getattr(py, "headers").unwrap().to_string(); + let headers_json = headers_raw.replace('\'', "\""); + let parsed: Value = serde_json::from_str(&headers_json).unwrap(); + let mut header_map = HeaderMap::new(); + if let Some(obj) = parsed.as_object() { + for (key, val) in obj { + if let Some(v) = val.as_str() { + let header_name = HeaderName::from_str(key).unwrap(); + let header_value: HeaderValue = HeaderValue::from_str(v).unwrap(); + header_map.insert(header_name, header_value); } } - ExtractedValues::Headers(header_map) - } else { - let base_url = py_self.getattr(py, "base_url").unwrap().to_string(); - ExtractedValues::BaseUrl(base_url) + } + let base_url = obj.getattr(py, "base_url").unwrap().to_string(); + Self { + headers: header_map, + base_url, } } } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index a0362f9..d54ceed 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -11,14 +11,9 @@ pub fn pull_policy_exec_histories( exec_types: String, days: i64, ) -> Py { - let headers: HeaderMap = match PyData::convert(py, &py_self, true) { - ExtractedValues::Headers(h) => h, - ExtractedValues::BaseUrl(_) => std::process::abort(), - }; - let base_url: String = match PyData::convert(py, &py_self, false) { - ExtractedValues::Headers(_) => std::process::abort(), - ExtractedValues::BaseUrl(b) => b, - }; + let data = PyData::extract_data(py, &py_self); + let headers = data.headers; + let base_url = data.base_url; let handle: thread::JoinHandle = std::thread::spawn(move || { let rt: tokio::runtime::Runtime = match tokio::runtime::Runtime::new() { Ok(rt) => rt, @@ -310,7 +305,11 @@ pub fn get_base_directory() -> PathBuf { .unwrap_or_else(|| home.join("AppData").join("Roaming")); appdata.join("Loxide") } - _ => home.join(".local").join("share").join("Loxide"), + "linux" => home.join(".local").join("share").join("Loxide"), + _ => { + println!("{} is currently not compatible with LoxideLibs", os); + std::process::abort(); + } } } diff --git a/requirements.txt b/requirements.txt index b11687f..71907d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==5.2.0 \ No newline at end of file +airlock_libs==5.2.1 \ No newline at end of file -- 2.34.1 From 797d0f44627cd4668410db94a5cd6ae530bfd351 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Thu, 11 Dec 2025 16:55:12 -0500 Subject: [PATCH 18/25] fix(policy-prep): implement table editors and workflow improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add table editors for Policy Prep workflow - 'Add to policy' remains a placeholder - Apply planned tweaks: - Replace ballot checkbox with βœ“ for selection - Relocate loading screen text to bottom: 'Building Path exclusions and publisher lists... This may take a moment for large datasets.' - Ensure interaction with all tables before allowing review steps - Move excessive logging to debug level - Add Step 0 to explain process before user begins Notes: Further discussion needed on enforcing table interaction before review. --- TUI/Screens/policyprepworkflowscreen.py | 1537 +++++++++++++++++++---- TUI/Widgets/prepPolicy.py | 48 +- services/agenthandler.py | 30 +- 3 files changed, 1305 insertions(+), 310 deletions(-) diff --git a/TUI/Screens/policyprepworkflowscreen.py b/TUI/Screens/policyprepworkflowscreen.py index 7035dc2..ceec0d6 100644 --- a/TUI/Screens/policyprepworkflowscreen.py +++ b/TUI/Screens/policyprepworkflowscreen.py @@ -17,6 +17,7 @@ import datetime import logging import os +import re from typing import Dict, List, Optional import pandas as pd @@ -80,12 +81,24 @@ class PolicyPrepWorkflowScreen(Screen): } #checklist_area { - max-height: 30%; - margin: 0 1 0 1; + max-height: 12; + margin: 0 1; } #content_area { height: 1fr; + overflow-y: auto; + scrollbar-gutter: stable; + padding: 1 1; + } + + Horizontal { + height: auto; + min-height: 3; + } + + Button { + min-width: 15; } Button.variant-error { @@ -103,11 +116,11 @@ class PolicyPrepWorkflowScreen(Screen): Binding("escape", "go_back", "Back"), Binding("q", "main_menu", "Main Menu"), Binding("f", "open_folder", "Open Folder"), - Binding("r", "refresh", "Refresh"), Binding("d", "delete_rows", "Delete Selected"), Binding("a", "select_all", "Select All"), Binding("n", "select_none", "Select None"), Binding("space", "toggle_selection", "Toggle Selection", show=False), + # Note: 'r' key handled in on_key() for range selection mode ] workflow_stage = reactive("select_source") # Tracks current workflow stage @@ -128,7 +141,7 @@ class PolicyPrepWorkflowScreen(Screen): self.destination_allowlist: Optional[Allowlist] = None self.working_dir = load_env("WORKING_DIR") or os.getcwd() self.history_days: Optional[int] = None - self.path_split: str = "\\\\" # Default path split for Windows + self.path_split: str = "\\" # Path separator for Windows (single backslash) # Data storage self.approved_df: Optional[pd.DataFrame] = None @@ -142,6 +155,16 @@ class PolicyPrepWorkflowScreen(Screen): # Test data for preview self.test_results: Optional[Dict] = None + # Multi-select tracking + self.last_clicked_row: Optional[str] = None + self.last_clicked_table: Optional[str] = None + + # Range selection mode (activated by 'r' key) + self._range_mode = False + + # Track if we're navigating with keyboard (to prevent selection) + self._keyboard_navigation = False + def compose(self) -> ComposeResult: """Build the UI layout for the workflow screen.""" yield Header(show_clock=True, icon="βš™οΈ") @@ -204,116 +227,126 @@ class PolicyPrepWorkflowScreen(Screen): # Checklist container with border checklist_container = Vertical() checklist_container.styles.border = ("round", "blue") - checklist_container.styles.margin = (1, 2) - checklist_container.styles.padding = 1 + checklist_container.styles.margin = (0, 1) + checklist_container.styles.padding = (0, 1) # Mount the container to the checklist area FIRST checklist.mount(checklist_container) - # NOW mount children to the container - checklist_title = Static("Preparation Checklist") + # Title + checklist_title = Static("Prep Checklist") checklist_title.styles.text_style = "bold" + checklist_title.styles.text_align = "center" checklist_container.mount(checklist_title) + # Create two-column layout + row1 = Horizontal() + row1.styles.height = "auto" + checklist_container.mount(row1) + + col1 = Vertical() + col1.styles.width = "50%" + col2 = Vertical() + col2.styles.width = "50%" + row1.mount(col1) + row1.mount(col2) + # Step 1: Source Policies - step1_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" - step1_text = f"{step1_status} Source Policies: " - if self.source_policies: - step1_text += ", ".join([p.name for p in self.source_policies[:3]]) - if len(self.source_policies) > 3: - step1_text += f" (+{len(self.source_policies)-3} more)" - else: - step1_text += "Not selected" - step1 = Static(step1_text) + step1_status = "βœ“" if self.source_policies else "βœ–" + step1_count = f" ({len(self.source_policies)})" if self.source_policies else "" + step1 = Static(f"{step1_status} Source{step1_count}") if self.source_policies: step1.styles.color = "green" else: step1.styles.text_style = "dim" - checklist_container.mount(step1) + col1.mount(step1) # Step 2: Destination Policy - step2_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" - step2_text = f"{step2_status} Destination Policy: " - step2_text += ( - self.destination_policy.name if self.destination_policy else "Not selected" - ) - step2 = Static(step2_text) + step2_status = "βœ“" if self.destination_policy else "βœ–" + step2 = Static(f"{step2_status} Destination") if self.destination_policy: step2.styles.color = "green" else: step2.styles.text_style = "dim" - checklist_container.mount(step2) + col1.mount(step2) - # Step 3: Destination Allowlist - step3_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" - step3_text = f"{step3_status} Allowlist: " - step3_text += ( - self.destination_allowlist.name - if self.destination_allowlist - else "Not selected" - ) - step3 = Static(step3_text) + # Step 3: Allowlist + step3_status = "βœ“" if self.destination_allowlist else "βœ–" + step3 = Static(f"{step3_status} Allowlist") if self.destination_allowlist: step3.styles.color = "green" else: step3.styles.text_style = "dim" - checklist_container.mount(step3) + col1.mount(step3) # Step 4: Data Fetched data_fetched = self.approved_df is not None or self.needs_review_df is not None - step4_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" - step4_text = f"{step4_status} Data Fetched: " + step4_status = "βœ“" if data_fetched else "βœ–" if data_fetched: total = 0 if self.approved_df is not None: total += len(self.approved_df) if self.needs_review_df is not None: total += len(self.needs_review_df) - step4_text += f"{total} executions" + step4 = Static(f"{step4_status} Data ({total})") else: - step4_text += "Not fetched" - step4 = Static(step4_text) + step4 = Static(f"{step4_status} Data") if data_fetched: step4.styles.color = "green" else: step4.styles.text_style = "dim" - checklist_container.mount(step4) + col1.mount(step4) - # Step 5: First Review Complete + # Step 5: First Review first_review_path = os.path.join(self.working_dir, "Approved") + first_review_done = False if self.source_policies: approved_file = os.path.join( first_review_path, f"{self.source_policies[0].name}_approved_executions.csv", ) first_review_done = os.path.exists(approved_file) - else: - first_review_done = False - step5_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" - step5_text = f"{step5_status} First Review: " - step5_text += "Complete" if first_review_done else "Pending" - step5 = Static(step5_text) + step5_status = "βœ“" if first_review_done else "βœ–" + step5 = Static(f"{step5_status} Review 1") if first_review_done: step5.styles.color = "green" else: step5.styles.text_style = "dim" - checklist_container.mount(step5) + col2.mount(step5) # Step 6: Paths Generated paths_generated = self.primary_paths_df is not None - step6_status = "βœ”οΈ" if self.destination_policy else "βœ–οΈ" - step6_text = f"{step6_status} Paths Generated: " + step6_status = "βœ“" if paths_generated else "βœ–" if paths_generated: - step6_text += f"{len(self.primary_paths_df)} primary paths" + step6 = Static(f"{step6_status} Paths ({len(self.primary_paths_df)})") else: - step6_text += "Not generated" - step6 = Static(step6_text) + step6 = Static(f"{step6_status} Paths") if paths_generated: step6.styles.color = "green" else: step6.styles.text_style = "dim" - checklist_container.mount(step6) + col2.mount(step6) + + # Step 7: Second Review + step7_status = ( + "βœ“" if self.workflow_stage in ["test", "liftoff", "complete"] else "βœ–" + ) + step7 = Static(f"{step7_status} Review 2") + if step7_status == "βœ“": + step7.styles.color = "green" + else: + step7.styles.text_style = "dim" + col2.mount(step7) + + # Step 8: Tested + step8_status = "βœ“" if self.workflow_stage in ["liftoff", "complete"] else "βœ–" + step8 = Static(f"{step8_status} Tested") + if step8_status == "βœ“": + step8.styles.color = "green" + else: + step8.styles.text_style = "dim" + col2.mount(step8) def _show_source_policy_selection(self) -> None: """Show the source policy selection screen.""" @@ -335,8 +368,11 @@ class PolicyPrepWorkflowScreen(Screen): # Add columns - checkbox first, then data columns table.add_columns("☐", "Name", "ID", "Parent") + # Sort policies by name for easier selection + sorted_policies = sorted(self.policies, key=lambda p: p.name.lower()) + # Add rows - for policy in self.policies: + for policy in sorted_policies: # Skip parent policies if policy.parent == "global-policy-settings": continue @@ -393,8 +429,9 @@ class PolicyPrepWorkflowScreen(Screen): instruction.styles.margin = (0, 1, 1, 1) content.mount(instruction) - # Create policy selector with single-select - policy_selector = PolicySelector(self.policies) + # Create policy selector with policies sorted alphabetically by name + sorted_policies = sorted(self.policies, key=lambda p: p.name.lower()) + policy_selector = PolicySelector(sorted_policies) content.mount(policy_selector) def _show_allowlist_selection(self) -> None: @@ -425,6 +462,9 @@ class PolicyPrepWorkflowScreen(Screen): ) return + # Sort allowlists alphabetically by name + allowlists = sorted(allowlists, key=lambda al: al.name.lower()) + # Add instruction instruction = Static("Click a row to select the allowlist for this policy:") instruction.styles.margin = (0, 1, 1, 1) @@ -455,9 +495,19 @@ class PolicyPrepWorkflowScreen(Screen): content = self.query_one("#content_area", Vertical) content.remove_children() + # Validate we have source policies before showing this screen + if not hasattr(self, "source_policies") or not self.source_policies: + logger.error("_show_fetch_data called but no source_policies set!") + self.app.notify( + "Error: No source policies selected. Returning to policy selection.", + severity="error", + ) + self._show_source_policy_selection() + return + instruction = Static( f"Ready to fetch execution history from: {', '.join([p.name for p in self.source_policies])}\n\n" - "Enter the number of days of history to fetch:" + "Enter the number of days of history to fetch (or press Enter to use default 150):" ) instruction.styles.margin = (0, 1, 1, 1) content.mount(instruction) @@ -465,11 +515,17 @@ class PolicyPrepWorkflowScreen(Screen): # Days input days_container = Horizontal() days_container.styles.margin = (1, 1) + days_container.styles.height = "auto" content.mount(days_container) - days_label = Static("History Days (1-150): ") - days_input = Input(value="150", id="history_days_input", type="integer") - days_input.styles.width = 20 + days_label = Static("History Days (1-365): ") + days_label.styles.width = "auto" + + days_input = Input( + value="150", placeholder="150", id="history_days_input", type="integer" + ) + days_input.styles.width = 30 + days_input.styles.min_width = 20 days_container.mount(days_label) days_container.mount(days_input) @@ -500,22 +556,82 @@ class PolicyPrepWorkflowScreen(Screen): button_container.mount(fetch_btn) button_container.mount(skip_btn) + # Set focus to the input field so it's ready for typing + def focus_input(): + try: + days_input.focus() + except Exception as e: + logger.debug(f"Could not focus input: {e}") + + self.call_after_refresh(focus_input) + def _fetch_execution_data(self, history_days: int) -> None: """Fetch and sort execution data.""" self.workflow_stage = "fetching" - content = self.query_one("#content_area", Vertical) - content.remove_children() - status = Static("Fetching execution history...\nThis may take a few moments...") - status.styles.margin = (2, 1) - content.mount(status) + # Show notification that fetch is starting + self.app.notify( + "Starting data fetch - this may take several minutes for large policies", + severity="information", + timeout=5, + ) - # Perform fetch in background - self.call_later(lambda: self._perform_fetch(history_days)) + # Clear the screen to provide a blank canvas for Rust progress output + # (Rust output displays over the TUI, so we clear everything except header/footer) + try: + # Clear title + title_widget = self.query_one("#workflow_title", Static) + title_widget.update("") + + # Clear status + status_widget = self.query_one("#workflow_status", Static) + status_widget.update("") + + # Clear content area + content = self.query_one("#content_area", Vertical) + content.remove_children() + except Exception as e: + logger.debug(f"Could not clear screen for fetch: {e}") + + # Delay the fetch start to ensure UI refresh completes first + # This prevents Rust output from starting before the screen is cleared + self.set_timer(0.5, lambda: self._perform_fetch(history_days)) def _perform_fetch(self, history_days: int) -> None: """Perform the actual data fetching.""" try: + # Validate we have source policies + logger.info(f"_perform_fetch called with history_days={history_days}") + logger.info( + f"self.source_policies exists: {hasattr(self, 'source_policies')}" + ) + + if hasattr(self, "source_policies"): + logger.info(f"self.source_policies type: {type(self.source_policies)}") + logger.info( + f"self.source_policies length: {len(self.source_policies) if self.source_policies else 0}" + ) + if self.source_policies: + logger.info( + f"First policy: {self.source_policies[0].name if self.source_policies else 'N/A'}" + ) + + if ( + not hasattr(self, "source_policies") + or not self.source_policies + or len(self.source_policies) == 0 + ): + logger.error( + f"No source policies selected. hasattr={hasattr(self, 'source_policies')}, value={getattr(self, 'source_policies', 'ATTR_MISSING')}" + ) + self.app.notify("No source policies selected!", severity="error") + self._show_fetch_data() + return + + logger.info( + f"Starting fetch for {len(self.source_policies)} policies, {history_days} days of history" + ) + # Fetch execution history policy_executions = ExecutionHistoryRecord.from_policies( self.api, @@ -524,12 +640,25 @@ class PolicyPrepWorkflowScreen(Screen): history_days=history_days, ) + logger.info(f"Fetched {len(policy_executions)} total execution records") + + if not policy_executions: + logger.warning("No execution records returned from API") + self.app.notify( + f"No execution history found for the last {history_days} days", + severity="warning", + ) + self._show_fetch_data() + return + # Enrich with hash data + logger.info("Enriching executions with hash data...") enriched_executions = ExecutionHistoryRecord.enrich_with_hashes( self.api, policy_executions ) # Categorize by hash decision + logger.info("Categorizing executions by hash decision...") categorized_executions = ( ExecutionHistoryRecord.categorize_executions_by_hash_decision( enriched_executions @@ -537,10 +666,16 @@ class PolicyPrepWorkflowScreen(Screen): ) # Sort by decision + logger.info("Sorting executions by decision...") approved, unapproved, needs_review, unknown = ( ExecutionHistoryRecord.sort_by_hash_decision(categorized_executions) ) + logger.info( + f"Sorted: {len(approved)} approved, {len(unapproved)} unapproved, " + f"{len(needs_review)} needs review, {len(unknown)} unknown" + ) + # Store the data self.approved_df = ( pd.DataFrame([r.__dict__ for r in approved]) @@ -559,9 +694,11 @@ class PolicyPrepWorkflowScreen(Screen): ) # Save to files + logger.info("Saving fetched data to files...") self._save_fetched_data() # Show results + logger.info("Showing results...") self._show_fetch_results() except Exception as e: @@ -602,10 +739,21 @@ class PolicyPrepWorkflowScreen(Screen): def _show_fetch_results(self) -> None: """Show the results of data fetching.""" + logger.info("=== _show_fetch_results called ===") self.workflow_stage = "first_review" content = self.query_one("#content_area", Vertical) content.remove_children() + logger.info( + f"Approved count: {len(self.approved_df) if self.approved_df is not None else 0}" + ) + logger.info( + f"Needs review count: {len(self.needs_review_df) if self.needs_review_df is not None else 0}" + ) + logger.info( + f"Unapproved count: {len(self.unapproved_df) if self.unapproved_df is not None else 0}" + ) + # Results summary approved_count = len(self.approved_df) if self.approved_df is not None else 0 review_count = ( @@ -624,6 +772,8 @@ class PolicyPrepWorkflowScreen(Screen): summary.styles.margin = (1, 1) content.mount(summary) + logger.info("Mounted summary widget") + # Tab selection for review tab_container = Horizontal() tab_container.styles.margin = (1, 1) @@ -639,22 +789,19 @@ class PolicyPrepWorkflowScreen(Screen): tab_container.mount(approved_tab_btn) tab_container.mount(review_tab_btn) + logger.info("Mounted tab buttons") + # Show approved table by default + logger.info("About to call _show_review_table('approved')") self._show_review_table("approved") + logger.info("=== _show_fetch_results complete ===") def _show_review_table(self, table_type: str) -> None: """Show an editable DataTable for reviewing executions.""" + logger.info(f"=== _show_review_table called with type: {table_type} ===") content = self.query_one("#content_area", Vertical) - # Remove existing table if any - existing_table = content.query("DataTable") - for table in existing_table: - table.remove() - existing_controls = content.query("#review_controls") - for control in existing_controls: - control.remove() - - # Determine which dataframe to show + # Determine which dataframe and table ID to show if table_type == "approved": df = self.approved_df table_id = "approved_review_table" @@ -664,10 +811,54 @@ class PolicyPrepWorkflowScreen(Screen): table_id = "needs_review_table" title = "Needs Review Executions - Select rows to REMOVE:" + # Sort DataFrame by filename (case-insensitive) and save back + if df is not None and not df.empty and "filename" in df.columns: + df = df.sort_values(by="filename", key=lambda x: x.str.lower()) + # Save sorted DataFrame back + if table_type == "approved": + self.approved_df = df + else: + self.needs_review_df = df + + # Remove the SPECIFIC table we're about to create if it exists + try: + existing_specific = content.query_one(f"#{table_id}", DataTable) + if existing_specific: + logger.debug(f"Removing existing table with ID: {table_id}") + existing_specific.remove() + except Exception as e: + logger.debug( + f"No existing table with ID {table_id} found (this is normal): {e}" + ) + + # Remove ALL existing DataTables to be safe + try: + existing_tables = content.query("DataTable") + logger.debug(f"Found {len(existing_tables)} existing tables to remove") + for table in existing_tables: + logger.debug(f"Removing table: {table.id}") + table.remove() + except Exception as e: + logger.debug(f"Error removing existing tables: {e}") + + # Force a refresh to ensure removals are processed + try: + content.refresh() + except Exception as e: + logger.debug(f"Error refreshing content: {e}") + + # Note: We no longer remove review_controls or review_continue_container + # They are reused between tabs to avoid DuplicateIds errors + + logger.info( + f"DataFrame for {table_type}: {'empty' if df is None or df.empty else f'{len(df)} rows'}" + ) + if df is None or df.empty: empty_msg = Static(f"No {table_type} executions to review") empty_msg.styles.margin = (2, 1) content.mount(empty_msg) + logger.info(f"No data for {table_type}, mounted empty message") return # Instructions @@ -678,8 +869,8 @@ class PolicyPrepWorkflowScreen(Screen): # Help text help_text = Static( - "Use arrows to navigate, SPACE to select/deselect rows, 'd' to delete selected rows\n" - "Selected rows will be highlighted and removed from the approved list" + "Click to toggle, 'r' for range select (click start, press 'r', click end)\n" + "Space to toggle cursor row, 'd' to delete, 'a' select all, arrows navigate" ) help_text.styles.margin = (0, 1, 1, 1) help_text.styles.text_style = "dim" @@ -687,18 +878,22 @@ class PolicyPrepWorkflowScreen(Screen): # Create the review table review_table = DataTable(id=table_id) - review_table.styles.height = "50vh" + review_table.styles.height = "40vh" # Increased since we removed button rows review_table.cursor_type = "row" review_table.zebra_stripes = True - # Add columns - checkbox first, then important fields + # Specified columns in order important_cols = [ - "filename", - "publisher", - "sha256", - "filepath", + "policyname", + "policyver", "hostname", - "datetime", + "username", + "publisher", + "filename", + "pprocess", + "gprocess", + "sha256", + "commandline", ] available_cols = [col for col in important_cols if col in df.columns] @@ -712,37 +907,49 @@ class PolicyPrepWorkflowScreen(Screen): row_data = [str(row.get(col, "")) for col in available_cols] review_table.add_row(checkbox, *row_data, key=str(idx)) + logger.info(f"About to mount {table_id}") + + # Final safety check - make sure no table with this ID exists before mounting + try: + final_check = content.query_one(f"#{table_id}", DataTable) + if final_check: + logger.warning( + f"Table {table_id} still exists after removal attempts! Forcing removal..." + ) + final_check.remove() + content.refresh() + except Exception: + # Good - table doesn't exist + pass + content.mount(review_table) + logger.info(f"Successfully mounted {table_id} with {len(df)} rows") - # Control buttons - control_container = Horizontal(id="review_controls") - control_container.styles.margin = (1, 1) - content.mount(control_container) - - select_all_btn = Button("Select All", id="select_all_rows") - select_all_btn.styles.margin = (0, 1, 0, 0) - - select_none_btn = Button("Clear Selection", id="select_none_rows") - select_none_btn.styles.margin = (0, 1, 0, 0) - - delete_btn = Button( - "Delete Selected", id="delete_selected_rows", variant="error" - ) - delete_btn.styles.margin = (0, 1, 0, 0) + # Row count display only (removed Select All, Clear, Delete buttons) + try: + control_container = content.query_one("#review_controls", Horizontal) + # Clear existing content + control_container.remove_children() + except Exception: + # Doesn't exist, create it + control_container = Horizontal(id="review_controls") + control_container.styles.margin = (1, 1) + control_container.styles.height = "auto" + control_container.styles.min_height = 1 + content.mount(control_container) row_count = Static(f"Total rows: {len(df)}") - row_count.styles.margin = (0, 1, 0, 2) + row_count.styles.margin = (0, 1, 0, 1) - control_container.mount(select_all_btn) - control_container.mount(select_none_btn) - control_container.mount(delete_btn) control_container.mount(row_count) # Continue button (always at bottom) if not content.query("#review_continue_container"): continue_container = Horizontal(id="review_continue_container") - continue_container.styles.margin = (2, 1, 0, 1) - continue_container.styles.dock = "bottom" + continue_container.styles.margin = (2, 1, 1, 1) + continue_container.styles.height = "auto" + continue_container.styles.min_height = 3 + # Removed dock="bottom" - was hiding content above # Mount the container to the content area FIRST content.mount(continue_container) @@ -778,14 +985,26 @@ class PolicyPrepWorkflowScreen(Screen): # Determine which dataframe to modify if self.current_review_type == "approved": df = self.approved_df + table_id = "approved_review_table" else: df = self.needs_review_df + table_id = "needs_review_table" if df is None: return - # Get indices to keep (not in selected rows) + # Get the table + try: + content = self.query_one("#content_area", Vertical) + table = content.query_one(f"#{table_id}", DataTable) + except Exception as e: + logger.error(f"Could not find table {table_id}: {e}") + return + + # Get indices to delete indices_to_delete = [int(idx) for idx in self.selected_rows] + + # Remove rows from DataFrame df_filtered = df.drop(index=indices_to_delete, errors="ignore") # Update the dataframe @@ -794,26 +1013,56 @@ class PolicyPrepWorkflowScreen(Screen): else: self.needs_review_df = df_filtered + # Remove rows from DataTable (don't rebuild entire table) + removed_count = 0 + failed_keys = [] + for idx in self.selected_rows: + try: + # Try to remove the row using the key + table.remove_row(idx) + removed_count += 1 + except Exception as e: + # Log but continue - some keys might not exist after DataFrame operations + logger.debug(f"Could not remove row {idx}: {e}") + failed_keys.append(idx) + # Clear selection self.selected_rows.clear() - # Refresh the table - self._show_review_table(self.current_review_type) + # Notify user + if failed_keys: + self.app.notify( + f"Deleted {removed_count} rows ({len(failed_keys)} already removed)", + severity="information", + ) + else: + self.app.notify(f"Deleted {removed_count} rows", severity="information") - self.app.notify( - f"Deleted {len(indices_to_delete)} rows", severity="information" - ) - - def _build_paths_and_publishers(self) -> None: - """Build path exclusions and publisher lists.""" - self.workflow_stage = "build_paths" + def _show_path_building_screen(self) -> None: + """Show loading screen before building paths and publishers.""" + self.workflow_stage = "building_paths" content = self.query_one("#content_area", Vertical) content.remove_children() - status = Static("Building path exclusions and publisher lists...") + # Clear message + status = Static( + "Building path exclusions and publisher lists...\n\n" + "This may take a moment for large datasets." + ) status.styles.margin = (2, 1) + status.styles.text_align = "center" content.mount(status) + # Force UI refresh to show the loading screen + content.refresh() + + # Schedule the actual build to happen after UI updates + # Using set_timer with a small delay ensures the screen renders + self.set_timer(0.1, self._perform_path_build) + + def _build_paths_and_publishers(self) -> None: + """Build path exclusions and publisher lists.""" + # Note: This is now bypassed - we go straight from _show_path_building_screen to _perform_path_build self.call_later(self._perform_path_build) def _perform_path_build(self) -> None: @@ -868,17 +1117,37 @@ class PolicyPrepWorkflowScreen(Screen): ) # Secondary paths - remaining = all_approved[ - ~all_approved["sha256"].isin(self.primary_paths_df["sha256"]) - ] - self.secondary_paths_df = self._calculate_paths( - remaining, path_exclusion_const - 1 - ) + if ( + not self.primary_paths_df.empty + and "sha256" in self.primary_paths_df.columns + ): + remaining = all_approved[ + ~all_approved["sha256"].isin(self.primary_paths_df["sha256"]) + ] + self.secondary_paths_df = self._calculate_paths( + remaining, path_exclusion_const - 1 + ) + else: + # If primary paths are empty, all remaining go to secondary + logger.warning( + "Primary paths DataFrame is empty or missing sha256 column" + ) + self.secondary_paths_df = pd.DataFrame() + remaining = all_approved # Remaining hashes - self.remaining_hashes_df = remaining[ - ~remaining["sha256"].isin(self.secondary_paths_df["sha256"]) - ] + if ( + not self.secondary_paths_df.empty + and "sha256" in self.secondary_paths_df.columns + ): + self.remaining_hashes_df = remaining[ + ~remaining["sha256"].isin(self.secondary_paths_df["sha256"]) + ] + else: + logger.warning( + "Secondary paths DataFrame is empty or missing sha256 column" + ) + self.remaining_hashes_df = remaining # Extract publishers if not all_approved.empty: @@ -909,28 +1178,236 @@ class PolicyPrepWorkflowScreen(Screen): self.app.notify(f"Failed to build paths: {str(e)}", severity="error") self._show_fetch_results() - def _calculate_paths(self, df: pd.DataFrame, depth: int) -> pd.DataFrame: - """Calculate path exclusions at specified depth.""" - # Simplified path calculation - in production this would be more complex - if df.empty or "filepath" not in df.columns: + def _regulator(self, string_list: List[str], case_insensitive: bool = True) -> str: + """ + Create a regex pattern from a list of strings. + + Args: + string_list: List of strings to create pattern from + case_insensitive: Whether to make pattern case insensitive + + Returns: + Regex pattern string that matches any of the input strings + """ + if not string_list: + return "" + + # Escape special regex characters in each string + escaped = [re.escape(s) for s in string_list] + + # Join with | (OR operator) + pattern = "|".join(escaped) + + return pattern + + def _split_filepaths_grouped( + self, df: pd.DataFrame, path_exclusion_constant: int, col: str = "filename" + ) -> pd.DataFrame: + """ + Split filepaths, group by common prefix, and extract metadata. + + This is a port of the splitFilepathsGrouped function from prepPolicy.py. + + Args: + df: DataFrame with filepath column + path_exclusion_constant: Depth for path truncation + col: Column name containing filepaths + + Returns: + DataFrame with columns: longestcfp, middle, filename_only, file_extension, + plus all original columns + """ + min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int) + + def clean_split(path): + """Split a path into parts, handling various input types.""" + if not isinstance(path, (str, bytes, os.PathLike)): + return [] + parts = str(os.path.normpath(path)).split(os.sep) + parts = [p for p in parts if p] # Remove empty strings + return parts + + # Check for non-string entries + non_string_entries = df[ + ~df[col].apply(lambda x: isinstance(x, (str, bytes, os.PathLike))) + ] + if not non_string_entries.empty: + logger.warning( + f"Non-string entries found in column '{col}': {len(non_string_entries)}" + ) + + df = df.copy() + split_paths = df[col].apply(clean_split) + + # Filter by minimum path length if configured + if min_files_for_path is not None: + df = df[ + split_paths.apply(lambda parts: len(parts) >= min_files_for_path) + ].copy() + split_paths = split_paths[df.index] + + # Group by path prefix + df["group_key"] = split_paths.apply( + lambda parts: os.sep.join(parts[:path_exclusion_constant]) + ) + grouped = df.groupby("group_key") + new_rows = [] + + for _, group_df in grouped: + paths = group_df[col].tolist() + split_parts = [clean_split(p) for p in paths] + + def longest_common_prefix(paths): + """Find the longest common prefix among a list of path parts.""" + if not paths: + return [] + prefix = paths[0] + for path in paths[1:]: + prefix = [a for a, b in zip(prefix, path) if a == b] + if not prefix: + break + return prefix + + common_prefix = longest_common_prefix(split_parts) + prefix_str = os.sep.join(common_prefix) + + # Process each file in the group + for i, parts in enumerate(split_parts): + filename = parts[-1] + middle = ( + os.sep.join(parts[len(common_prefix) : -1]) + if len(parts) > len(common_prefix) + 1 + else "" + ) + row = group_df.iloc[i].copy() + row["longestcfp"] = prefix_str + row["middle"] = middle + row["filename_only"] = filename + row["file_extension"] = os.path.splitext(filename)[1].lower() + new_rows.append(row) + + result = pd.DataFrame(new_rows).drop(columns=["group_key"]) + logger.info( + f"_split_filepaths_grouped: Processed {len(df)} rows β†’ {len(result)} rows with metadata" + ) + return result + + def _calculate_paths( + self, df: pd.DataFrame, path_exclusion_constant: int + ) -> pd.DataFrame: + """ + Calculate path exclusions with full metadata including extensions and hash counts. + + This is a port of the calculatePath function from prepPolicy.py. + + Args: + df: DataFrame with execution data + path_exclusion_constant: Depth for path truncation + + Returns: + DataFrame with columns: policyname, longestcfp, middle, filename_only, + file_extension, sha256, unique_sha256_count + """ + logger.info( + f"=== _calculate_paths called with path_exclusion_constant={path_exclusion_constant} ===" + ) + logger.info(f"Input DataFrame: {len(df)} rows") + logger.info(f"Columns: {list(df.columns) if not df.empty else 'empty'}") + + if df.empty: + logger.warning("Input DataFrame is empty") return pd.DataFrame() - paths = [] - for filepath in df["filepath"].unique(): - if pd.isna(filepath): - continue - parts = filepath.split(self.path_split) - if len(parts) > depth: - truncated = self.path_split.join(parts[:depth]) - paths.append(truncated) + # Use 'filename' column (which typically contains full path) + if "filename" not in df.columns: + logger.error("'filename' column not found in DataFrame") + return pd.DataFrame() - # Create dataframe with unique paths - if paths: - path_df = pd.DataFrame({"longestcfp": list(set(paths))}) - # Add mock sha256 for compatibility - path_df["sha256"] = path_df.index.astype(str) - return path_df - return pd.DataFrame() + # Split filepaths and extract metadata + haslcp = self._split_filepaths_grouped(df, path_exclusion_constant, "filename") + haslcp = haslcp.drop_duplicates() + + logger.info(f"After split_filepaths_grouped: {len(haslcp)} rows") + + # Filter forbidden paths + badpathparts = get_system_list("BAD_PATH_PARTS") + if badpathparts: + forbidden_pattern = self._regulator(badpathparts, True) + forbidden_lcfp = haslcp["longestcfp"].str.contains( + forbidden_pattern, case=False, na=False, regex=True + ) + + logger.info( + f"Removing forbidden filepaths: {forbidden_lcfp.sum()} paths filtered" + ) + lcp_not_forbidden = haslcp[~forbidden_lcfp].copy() + else: + logger.info( + "No BAD_PATH_PARTS configured, skipping forbidden path filtering" + ) + lcp_not_forbidden = haslcp.copy() + + logger.info(f"After forbidden filtering: {len(lcp_not_forbidden)} rows") + + # Select relevant columns + if "policyname" in lcp_not_forbidden.columns: + columns_to_keep = [ + "policyname", + "longestcfp", + "middle", + "filename_only", + "file_extension", + "sha256", + ] + else: + # If no policyname, skip it + columns_to_keep = [ + "longestcfp", + "middle", + "filename_only", + "file_extension", + "sha256", + ] + + # Only keep columns that exist + columns_to_keep = [ + col for col in columns_to_keep if col in lcp_not_forbidden.columns + ] + lcp_not_forbidden_review = lcp_not_forbidden[columns_to_keep] + + # Count unique SHA256s per path + unique_sha_counts = ( + lcp_not_forbidden_review.groupby("longestcfp")["sha256"] + .nunique() + .reset_index() + ) + unique_sha_counts.columns = ["longestcfp", "unique_sha256_count"] + + logger.info( + f"Calculated unique SHA256 counts for {len(unique_sha_counts)} paths" + ) + + # Merge counts back into main DataFrame + lcp_not_forbidden_review = lcp_not_forbidden_review.merge( + unique_sha_counts, on="longestcfp", how="left" + ) + + # Filter by minimum files per path + min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int) + if min_files_for_path is not None: + before_filter = len(lcp_not_forbidden_review) + lcp_not_forbidden_review = lcp_not_forbidden_review[ + lcp_not_forbidden_review["unique_sha256_count"] >= min_files_for_path + ] + logger.info( + f"After MIN_FILES_FOR_PATH filter ({min_files_for_path}): {len(lcp_not_forbidden_review)} rows (removed {before_filter - len(lcp_not_forbidden_review)})" + ) + + logger.info( + f"Final result: {len(lcp_not_forbidden_review)} rows with columns: {list(lcp_not_forbidden_review.columns)}" + ) + + return lcp_not_forbidden_review def _save_path_data(self) -> None: """Save path and publisher data to files.""" @@ -964,6 +1441,7 @@ class PolicyPrepWorkflowScreen(Screen): def _show_path_results(self) -> None: """Show the results of path building.""" + logger.info("=== _show_path_results called ===") self.workflow_stage = "second_review" content = self.query_one("#content_area", Vertical) content.remove_children() @@ -979,6 +1457,10 @@ class PolicyPrepWorkflowScreen(Screen): len(self.publishers_df) if self.publishers_df is not None else 0 ) + logger.info(f"Primary paths: {primary_count}") + logger.info(f"Secondary paths: {secondary_count}") + logger.info(f"Publishers: {publishers_count}") + summary = Static( f"Path Analysis Complete!\n\n" f"Primary Paths: {primary_count}\n" @@ -987,6 +1469,7 @@ class PolicyPrepWorkflowScreen(Screen): ) summary.styles.margin = (1, 1) content.mount(summary) + logger.info("Mounted summary widget") # Tab selection for different review types tab_container = Horizontal() @@ -1004,22 +1487,18 @@ class PolicyPrepWorkflowScreen(Screen): tab_container.mount(paths_tab_btn) tab_container.mount(publishers_tab_btn) tab_container.mount(remaining_tab_btn) + logger.info("Mounted tab buttons") # Show paths table by default + logger.info("About to call _show_path_review_table('paths')") self._show_path_review_table("paths") + logger.info("=== _show_path_results complete ===") def _show_path_review_table(self, table_type: str) -> None: """Show an editable DataTable for reviewing paths/publishers.""" + logger.info(f"=== _show_path_review_table called with type: {table_type} ===") content = self.query_one("#content_area", Vertical) - # Remove existing table if any - existing_table = content.query("DataTable") - for table in existing_table: - table.remove() - existing_controls = content.query("#path_review_controls") - for control in existing_controls: - control.remove() - # Determine which dataframe to show if table_type == "paths": # Combine primary and secondary paths for review @@ -1038,12 +1517,51 @@ class PolicyPrepWorkflowScreen(Screen): if dfs: df = pd.concat(dfs, ignore_index=True) + + # Aggregate by path to show one row per path + if not df.empty and "longestcfp" in df.columns: + # Group by longestcfp and type, aggregate extensions + aggregated_rows = [] + for (path, path_type), group in df.groupby(["longestcfp", "type"]): + # Get unique extensions and hash count + extensions = ( + group["file_extension"].unique() + if "file_extension" in group.columns + else [] + ) + extensions_str = ", ".join( + sorted(set(ext for ext in extensions if ext)) + ) + + # Get hash count (should be same for all rows with same longestcfp) + hash_count = ( + group["unique_sha256_count"].iloc[0] + if "unique_sha256_count" in group.columns + else 0 + ) + + aggregated_rows.append( + { + "longestcfp": path, + "file_extension": extensions_str, + "unique_sha256_count": hash_count, + "type": path_type, + } + ) + + df = pd.DataFrame(aggregated_rows) + logger.info(f"Aggregated paths: {len(df)} unique paths") else: df = pd.DataFrame() table_id = "paths_review_table" title = "Path Exclusions - Select paths to REMOVE:" - columns = ["longestcfp", "type"] if not df.empty else [] + # Show: path, extensions, hash count, type (primary/secondary) + columns = ( + ["longestcfp", "file_extension", "unique_sha256_count", "type"] + if not df.empty + else [] + ) elif table_type == "publishers": df = self.publishers_df @@ -1061,6 +1579,41 @@ class PolicyPrepWorkflowScreen(Screen): else [] ) + # Remove the SPECIFIC table we're about to create if it exists + try: + existing_specific = content.query_one(f"#{table_id}", DataTable) + if existing_specific: + logger.debug(f"Removing existing table with ID: {table_id}") + existing_specific.remove() + except Exception as e: + logger.debug( + f"No existing table with ID {table_id} found (this is normal): {e}" + ) + + # Remove any other existing tables + try: + existing_tables = content.query("DataTable") + logger.debug(f"Found {len(existing_tables)} existing tables to remove") + for table in existing_tables: + table.remove() + except Exception as e: + logger.debug(f"Error removing existing tables: {e}") + + # Remove existing controls + try: + existing_controls = content.query("#path_review_controls") + logger.debug(f"Found {len(existing_controls)} existing controls to remove") + for control in existing_controls: + control.remove() + except Exception as e: + logger.debug(f"Error removing existing controls: {e}") + + # Force refresh to ensure removals complete + try: + content.refresh() + except Exception as e: + logger.debug(f"Error refreshing content: {e}") + if df is None or df.empty: empty_msg = Static(f"No {table_type} to review") empty_msg.styles.margin = (2, 1) @@ -1076,8 +1629,8 @@ class PolicyPrepWorkflowScreen(Screen): # Help text (different for remaining hashes) if table_type != "remaining": help_text = Static( - "Use arrows to navigate, SPACE to select/deselect rows, 'd' to delete selected rows\n" - "Selected items will be removed from the final approval list" + "Click to toggle, 'r' for range select (click start, press 'r', click end)\n" + "Space to toggle cursor row, 'd' to delete, 'a' select all, arrows navigate" ) else: help_text = Static( @@ -1090,7 +1643,7 @@ class PolicyPrepWorkflowScreen(Screen): # Create the review table review_table = DataTable(id=table_id) - review_table.styles.height = "40vh" + review_table.styles.height = "35vh" # Increased since we removed button rows review_table.cursor_type = "row" review_table.zebra_stripes = True @@ -1105,42 +1658,91 @@ class PolicyPrepWorkflowScreen(Screen): # Add rows with row keys for tracking for idx, row in df.iterrows(): checkbox = "☐" # All start unchecked - row_data = [str(row.get(col, "")) for col in available_cols] + row_data = [] + for col in available_cols: + value = row.get(col, "") + # Format unique_sha256_count with commas + if col == "unique_sha256_count" and isinstance( + value, (int, float) + ): + value = f"{int(value):,}" + row_data.append(str(value)) review_table.add_row(checkbox, *row_data, key=str(idx)) + logger.info(f"About to mount {table_id}") + + # Final safety check before mounting table + try: + final_check = content.query_one(f"#{table_id}", DataTable) + if final_check: + logger.warning(f"Table {table_id} still exists! Forcing removal...") + final_check.remove() + content.refresh() + except Exception: + pass + content.mount(review_table) + logger.info(f"Successfully mounted {table_id}") - # Control buttons (not for remaining hashes view) + # Row count display only (removed Select All, Clear, Delete buttons) if table_type != "remaining": - control_container = Horizontal(id="path_review_controls") - control_container.styles.margin = (1, 1) - content.mount(control_container) - - select_all_btn = Button("Select All", id="select_all_path_rows") - select_all_btn.styles.margin = (0, 1, 0, 0) - - select_none_btn = Button("Clear Selection", id="select_none_path_rows") - select_none_btn.styles.margin = (0, 1, 0, 0) - - delete_btn = Button( - "Delete Selected", id="delete_selected_path_rows", variant="error" - ) - delete_btn.styles.margin = (0, 1, 0, 0) + # Check if controls container already exists, reuse if it does + try: + control_container = content.query_one( + "#path_review_controls", Horizontal + ) + # Clear existing content + control_container.remove_children() + logger.debug("Reusing existing path_review_controls container") + except Exception: + # Doesn't exist, create it + control_container = Horizontal(id="path_review_controls") + control_container.styles.margin = (1, 1) + control_container.styles.height = "auto" + control_container.styles.min_height = 1 + content.mount(control_container) + logger.debug("Created new path_review_controls container") row_count = Static(f"Total items: {len(df)}") - row_count.styles.margin = (0, 1, 0, 2) + row_count.styles.margin = (0, 1, 0, 1) - control_container.mount(select_all_btn) - control_container.mount(select_none_btn) - control_container.mount(delete_btn) control_container.mount(row_count) # Continue button (always at bottom) - if not content.query("#path_continue_container"): - continue_container = Horizontal(id="path_continue_container") - continue_container.styles.margin = (2, 1, 0, 1) - continue_container.styles.dock = "bottom" + try: + continue_container = content.query_one( + "#path_continue_container", Horizontal + ) + # Container exists, check if buttons exist + try: + export_btn = continue_container.query_one("#export_path_review", Button) + continue_btn = continue_container.query_one("#build_preflight", Button) + logger.debug("Reusing existing path_continue_container with buttons") + # Buttons already exist, just reuse them + except Exception: + # Container exists but buttons don't, clear and create new + continue_container.remove_children() + logger.debug("Reusing container, creating new buttons") + export_btn = Button("Export to CSV", id="export_path_review") + export_btn.styles.margin = (0, 1, 0, 0) + + continue_btn = Button( + "Build Preflight", id="build_preflight", variant="success" + ) + + continue_container.mount(export_btn) + continue_container.mount(continue_btn) + except Exception: + # Container doesn't exist, create it with buttons + continue_container = Horizontal(id="path_continue_container") + continue_container.styles.margin = (2, 1, 1, 1) + continue_container.styles.height = "auto" + continue_container.styles.min_height = 3 + content.mount(continue_container) + logger.debug("Created new path_continue_container") + + # Create and mount buttons export_btn = Button("Export to CSV", id="export_path_review") export_btn.styles.margin = (0, 1, 0, 0) @@ -1150,7 +1752,6 @@ class PolicyPrepWorkflowScreen(Screen): continue_container.mount(export_btn) continue_container.mount(continue_btn) - content.mount(continue_container) # Track selected rows if not hasattr(self, "selected_path_rows"): @@ -1169,6 +1770,22 @@ class PolicyPrepWorkflowScreen(Screen): indices_to_delete = [int(idx) for idx in self.selected_path_rows] + # Get the appropriate table + if self.current_path_review_type == "paths": + table_id = "paths_review_table" + elif self.current_path_review_type == "publishers": + table_id = "publishers_review_table" + else: + table_id = "remaining_review_table" + + # Get the table + try: + content = self.query_one("#content_area", Vertical) + table = content.query_one(f"#{table_id}", DataTable) + except Exception as e: + logger.error(f"Could not find table {table_id}: {e}") + return + # Determine which dataframe to modify if self.current_path_review_type == "paths": # Need to handle primary and secondary paths @@ -1198,12 +1815,16 @@ class PolicyPrepWorkflowScreen(Screen): index=indices_to_delete, errors="ignore" ) + # Remove rows from DataTable (don't rebuild entire table) + for idx in self.selected_path_rows: + try: + table.remove_row(idx) + except Exception as e: + logger.debug(f"Could not remove row {idx}: {e}") + # Clear selection self.selected_path_rows.clear() - # Refresh the table - self._show_path_review_table(self.current_path_review_type) - self.app.notify( f"Deleted {len(indices_to_delete)} items", severity="information" ) @@ -1219,48 +1840,81 @@ class PolicyPrepWorkflowScreen(Screen): self.app.notify(f"Failed to build preflight: {str(e)}", severity="error") def _show_test_screen(self) -> None: - """Show the test/preview screen.""" + """Show the test/preview screen with detailed path listings.""" self.workflow_stage = "test" content = self.query_one("#content_area", Vertical) content.remove_children() summary = Static( - "Test Mode - Preview Changes\n\n" "The following changes will be applied:\n" + "Test Mode - Preview Changes\n\n" + "Review the paths that will be added to your policy:" ) summary.styles.margin = (1, 1) + summary.styles.text_style = "bold" content.mount(summary) - # Show what would be changed - changes_text = "" - + # Policy and Allowlist info + info_text = "" if self.destination_policy: - changes_text += f"Policy: {self.destination_policy.name}\n" - - if self.primary_paths_df is not None and not self.primary_paths_df.empty: - changes_text += f"Add {len(self.primary_paths_df)} path exclusions\n" - - if self.publishers_df is not None and not self.publishers_df.empty: - changes_text += f"Add {len(self.publishers_df)} approved publishers\n" - + info_text += f"πŸ“‹ Policy: {self.destination_policy.name}\n" if self.destination_allowlist: - changes_text += f"\nAllowlist: {self.destination_allowlist.name}\n" + info_text += f"πŸ“‹ Allowlist: {self.destination_allowlist.name}\n" + if info_text: + info = Static(info_text) + info.styles.margin = (0, 1, 1, 1) + content.mount(info) + + # Show detailed path exclusions + if self.primary_paths_df is not None and not self.primary_paths_df.empty: + self._show_path_preview( + content, "Primary Path Exclusions", self.primary_paths_df + ) + + if self.secondary_paths_df is not None and not self.secondary_paths_df.empty: + self._show_path_preview( + content, "Secondary Path Exclusions", self.secondary_paths_df + ) + + # Show publishers + if self.publishers_df is not None and not self.publishers_df.empty: + pub_title = Static( + f"\nπŸ“ Trusted Publishers ({len(self.publishers_df)} publishers):" + ) + pub_title.styles.margin = (1, 1, 0, 1) + pub_title.styles.text_style = "bold" + content.mount(pub_title) + + # Create scrollable table for publishers + pub_table = DataTable(id="publisher_preview_table") + pub_table.styles.height = "15vh" + pub_table.styles.margin = (0, 1) + pub_table.cursor_type = "row" + pub_table.zebra_stripes = True + pub_table.add_column("Publisher") + + for _, row in self.publishers_df.iterrows(): + pub_table.add_row(row["publisher"]) + + content.mount(pub_table) + + # Show hash count if self.approved_df is not None and not self.approved_df.empty: - changes_text += f"Add {len(self.approved_df)} approved hashes\n" - - changes = Static(changes_text) - changes.styles.margin = (0, 2) - changes.styles.border = ("round", "cyan") - changes.styles.padding = 1 - content.mount(changes) + hash_info = Static( + f"\nπŸ” Individual Hash Approvals: {len(self.approved_df):,} hashes\n" + f" (Files not covered by paths or publishers)" + ) + hash_info.styles.margin = (1, 1) + content.mount(hash_info) # Warning warning = Static( - " Òő ï¸Warning: These changes cannot be easily undone.Òő ï¸\n" - "Please review carefully before proceeding." + "\n⚠️ WARNING: These changes cannot be easily undone. ⚠️\n" + "Please review all paths carefully before proceeding." ) warning.styles.margin = (1, 1) warning.styles.color = "yellow" + warning.styles.text_style = "bold" content.mount(warning) # Buttons @@ -1268,10 +1922,67 @@ class PolicyPrepWorkflowScreen(Screen): button_container.styles.margin = (2, 1) content.mount(button_container) - liftoff_btn = Button("Liftoff - Apply Changes", id="liftoff", variant="success") + back_btn = Button( + "← Back to Review", id="back_to_path_review", variant="default" + ) + liftoff_btn = Button( + "Liftoff - Apply Changes πŸš€", id="liftoff", variant="success" + ) + button_container.mount(back_btn) button_container.mount(liftoff_btn) + def _show_path_preview( + self, content: Vertical, title: str, paths_df: pd.DataFrame + ) -> None: + """Show a preview of paths that will be added.""" + # Aggregate paths by longestcfp to get unique paths with all extensions + path_list = [] + + for path, group in paths_df.groupby("longestcfp"): + # Get all unique extensions for this path + extensions = ( + group["file_extension"].unique() + if "file_extension" in group.columns + else [] + ) + extensions = sorted(set(ext for ext in extensions if ext)) + + # Create path rules for each extension + for ext in extensions: + # Format the path as it will appear in Airlock + # Example: C:\Program Files\App\**.exe + formatted_path = f"{path}\\**{ext}" + + # Get hash count for this specific path+extension combo + hash_count = ( + len(group[group["file_extension"] == ext]) + if "file_extension" in group.columns + else 0 + ) + + path_list.append((formatted_path, hash_count)) + + # Show title with count + path_title = Static(f"\nπŸ“ {title} ({len(path_list)} path rules):") + path_title.styles.margin = (1, 1, 0, 1) + path_title.styles.text_style = "bold" + content.mount(path_title) + + # Create scrollable table + path_table = DataTable(id=f"{title.lower().replace(' ', '_')}_table") + path_table.styles.height = "20vh" + path_table.styles.margin = (0, 1) + path_table.cursor_type = "row" + path_table.zebra_stripes = True + path_table.add_columns("Path Rule", "Files Covered") + + # Add rows + for path_rule, hash_count in sorted(path_list): + path_table.add_row(path_rule, str(hash_count)) + + content.mount(path_table) + def _apply_changes(self) -> None: """Apply the changes to policies and allowlists.""" self.workflow_stage = "liftoff" @@ -1361,37 +2072,134 @@ class PolicyPrepWorkflowScreen(Screen): # Update checkboxes in place without rebuilding the table row_index = 0 for row_key in table.rows.keys(): + # Get the actual value from the RowKey object + row_key_str = ( + str(row_key.value) if hasattr(row_key, "value") else str(row_key) + ) # Determine if this row should be checked - checkbox = "β˜‘οΈ" if str(row_key) in selected_keys else "☐" + is_selected = row_key_str in selected_keys + checkbox = "β˜‘οΈ" if is_selected else "☐" # Update the checkbox cell (first column, index 0) try: table.update_cell_at((row_index, 0), checkbox) except Exception as e: - logger.debug(f"Could not update cell at row {row_index}: {e}") + logger.error(f"Could not update cell at row {row_index}: {e}") row_index += 1 except Exception as e: - logger.debug(f"Error refreshing table {table_id}: {e}") + logger.error(f"Error refreshing table {table_id}: {e}", exc_info=True) - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - """Handle row selection in data tables.""" + def _get_selected_set(self, table_id: str) -> set: + """Get the appropriate selection set for a table.""" + if table_id == "source_policy_table": + return self.selected_source_policy_ids + elif table_id in ["approved_review_table", "needs_review_table"]: + return self.selected_rows + elif table_id in [ + "paths_review_table", + "publishers_review_table", + "remaining_review_table", + ]: + return self.selected_path_rows + return set() + + def _range_select(self, table: DataTable, start_key: str, end_key: str) -> None: + """Toggle all rows between start and end (inclusive).""" + # Get all row keys in order + all_keys = [ + str(k.value if hasattr(k, "value") else k) for k in table.rows.keys() + ] + + try: + start_idx = all_keys.index(start_key) + end_idx = all_keys.index(end_key) + except ValueError: + # Key not found, fall back to single toggle + logger.warning("Range select failed: keys not found") + return + + # Ensure start < end + if start_idx > end_idx: + start_idx, end_idx = end_idx, start_idx + + # Toggle all rows in range + selected_set = self._get_selected_set(table.id) + range_keys = [all_keys[i] for i in range(start_idx, end_idx + 1)] + + # Determine if we're selecting or deselecting + # If any row in range is unselected, select all; otherwise deselect all + any_unselected = any(key not in selected_set for key in range_keys) + + if any_unselected: + # Select all in range + for row_key in range_keys: + selected_set.add(row_key) + action = "selected" + else: + # Deselect all in range + for row_key in range_keys: + selected_set.discard(row_key) + action = "deselected" + + # Refresh display + self._refresh_table_checkboxes(table.id, selected_set) + + # Notify user + count = end_idx - start_idx + 1 + self.app.notify(f"Range {action} ({count} rows)", timeout=2) + + def _toggle_single(self, table: DataTable, row_key: str) -> None: + """Toggle a single row without affecting others (Ctrl+Click).""" + selected_set = self._get_selected_set(table.id) + + # Toggle + if row_key in selected_set: + selected_set.remove(row_key) + else: + selected_set.add(row_key) + + # Refresh + self._refresh_table_checkboxes(table.id, selected_set) + self.app.notify(f"Selected {len(selected_set)} rows", timeout=1) + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + """Handle row highlighting (clicking) in data tables with range selection support.""" table = event.data_table + row_key = ( + str(event.row_key.value) + if hasattr(event.row_key, "value") + else str(event.row_key) + ) + + # If this was triggered by keyboard navigation, skip selection and reset flag + if self._keyboard_navigation: + self._keyboard_navigation = False + logger.debug("KEYBOARD NAV: Ignoring row highlight from arrow keys") + return + + logger.info( + f"CLICK: table={table.id}, row={row_key}, range_mode={self._range_mode}" + ) # Handle source policy selection if table.id == "source_policy_table": - # Use cursor_row for reliable row index - row_index = table.cursor_row - # Get the row key from the table - row_keys = list(table.rows.keys()) - if row_index < len(row_keys): - row_key = str(row_keys[row_index]) + if ( + self._range_mode + and self.last_clicked_row + and self.last_clicked_table == table.id + ): + # Range toggle + logger.info(f"RANGE TOGGLE: from {self.last_clicked_row} to {row_key}") + self._range_select(table, self.last_clicked_row, row_key) + self._range_mode = False # Exit range mode after operation + else: + # Normal toggle if row_key in self.selected_source_policy_ids: self.selected_source_policy_ids.remove(row_key) else: self.selected_source_policy_ids.add(row_key) - # Refresh checkbox display self._refresh_table_checkboxes( table.id, self.selected_source_policy_ids ) @@ -1400,8 +2208,86 @@ class PolicyPrepWorkflowScreen(Screen): timeout=1, ) - # Handle allowlist selection - elif table.id == "allowlist_table": + # Remember for next range-select + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + # Handle path/publisher review selections + elif table.id in [ + "paths_review_table", + "publishers_review_table", + "remaining_review_table", + ]: + if ( + self._range_mode + and self.last_clicked_row + and self.last_clicked_table == table.id + ): + # Range toggle + logger.info(f"RANGE TOGGLE: from {self.last_clicked_row} to {row_key}") + self._range_select(table, self.last_clicked_row, row_key) + self._range_mode = False # Exit range mode after operation + else: + # Normal toggle + if row_key in self.selected_path_rows: + self.selected_path_rows.remove(row_key) + else: + self.selected_path_rows.add(row_key) + self._refresh_table_checkboxes(table.id, self.selected_path_rows) + self.app.notify( + f"Selected {len(self.selected_path_rows)} items", timeout=1 + ) + + # Remember for next range-select + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + # Handle approved/needs review selections + elif table.id in ["approved_review_table", "needs_review_table"]: + if ( + self._range_mode + and self.last_clicked_row + and self.last_clicked_table == table.id + ): + # Range toggle + logger.info(f"RANGE TOGGLE: from {self.last_clicked_row} to {row_key}") + self._range_select(table, self.last_clicked_row, row_key) + self._range_mode = False # Exit range mode after operation + else: + # Normal toggle + if row_key in self.selected_rows: + self.selected_rows.remove(row_key) + else: + self.selected_rows.add(row_key) + self._refresh_table_checkboxes(table.id, self.selected_rows) + self.app.notify(f"Selected {len(self.selected_rows)} rows", timeout=1) + + # Remember for next range-select + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle row selection in data tables.""" + table = event.data_table + logger.debug(f"Row selected in table: {table.id}") + + # NOTE: Review table selections are handled in on_data_table_row_highlighted (clicks only) + # This event (row_selected) is triggered by arrow key navigation, which should NOT select + # Only handle special cases like allowlist selection + + # Ignore all review tables - they use row_highlighted for selection + if table.id in [ + "source_policy_table", + "approved_review_table", + "needs_review_table", + "paths_review_table", + "publishers_review_table", + "remaining_review_table", + ]: + return + + # Handle allowlist selection (this one uses row selection, not highlighting) + if table.id == "allowlist_table": # Get selected allowlist row_index = table.cursor_row if hasattr(self, "allowlists") and row_index < len(self.allowlists): @@ -1409,39 +2295,21 @@ class PolicyPrepWorkflowScreen(Screen): logger.info(f"Selected allowlist: {self.destination_allowlist.name}") self._show_fetch_data() - # Handle review table selections (toggle selection) - elif table.id in ["approved_review_table", "needs_review_table"]: - # Use cursor_row for reliable row index - row_index = table.cursor_row - # Get the row key from the table - row_keys = list(table.rows.keys()) - if row_index < len(row_keys): - row_key = str(row_keys[row_index]) - if row_key in self.selected_rows: - self.selected_rows.remove(row_key) + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle input submission (Enter key press).""" + if event.input.id == "history_days_input": + # Trigger the fetch when user presses Enter in the days input + try: + history_days = int(event.input.value) + if 1 <= history_days <= 365: + self.history_days = history_days + self._fetch_execution_data(history_days) else: - self.selected_rows.add(row_key) - # Refresh checkbox display - self._refresh_table_checkboxes(table.id, self.selected_rows) - self.app.notify(f"Selected {len(self.selected_rows)} rows", timeout=1) - - # Handle path/publisher review selections - elif table.id in ["paths_review_table", "publishers_review_table"]: - # Use cursor_row for reliable row index - row_index = table.cursor_row - # Get the row key from the table - row_keys = list(table.rows.keys()) - if row_index < len(row_keys): - row_key = str(row_keys[row_index]) - if row_key in self.selected_path_rows: - self.selected_path_rows.remove(row_key) - else: - self.selected_path_rows.add(row_key) - # Refresh checkbox display - self._refresh_table_checkboxes(table.id, self.selected_path_rows) - self.app.notify( - f"Selected {len(self.selected_path_rows)} items", timeout=1 - ) + self.app.notify( + "Please enter a value between 1 and 365", severity="warning" + ) + except (ValueError, TypeError): + self.app.notify("Please enter a valid number", severity="warning") def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" @@ -1471,7 +2339,18 @@ class PolicyPrepWorkflowScreen(Screen): logger.info( f"Selected source policies: {[p.name for p in self.source_policies]}" ) - self._show_destination_policy_selection() + logger.info( + f"self.source_policies set to: {len(self.source_policies)} policies" + ) + + if not self.source_policies: + logger.error("source_policies list is empty after selection!") + self.app.notify( + "Error: Could not load selected policies. Please try again.", + severity="error", + ) + else: + self._show_destination_policy_selection() # Tab switching buttons elif button_id == "show_approved_tab": @@ -1521,12 +2400,12 @@ class PolicyPrepWorkflowScreen(Screen): try: days_input = self.query_one("#history_days_input", Input) history_days = int(days_input.value) - if 1 <= history_days <= 150: + if 1 <= history_days <= 365: self.history_days = history_days self._fetch_execution_data(history_days) else: self.app.notify( - "Please enter a value between 1 and 150", severity="warning" + "Please enter a value between 1 and 365", severity="warning" ) except (ValueError, TypeError): self.app.notify("Please enter a valid number", severity="warning") @@ -1566,7 +2445,8 @@ class PolicyPrepWorkflowScreen(Screen): else: # Save the reviewed data before continuing self._save_reviewed_data() - self._build_paths_and_publishers() + # Show loading screen then build paths + self._show_path_building_screen() elif button_id == "build_preflight": # Validate that path review is complete @@ -1579,6 +2459,10 @@ class PolicyPrepWorkflowScreen(Screen): else: self._build_preflight() + elif button_id == "back_to_path_review": + # Go back to path review screen + self._show_path_review_table("paths") + elif button_id == "liftoff": # Confirm before applying self.app.notify("Applying changes...", severity="information") @@ -1599,7 +2483,8 @@ class PolicyPrepWorkflowScreen(Screen): df = self.needs_review_df if table_id and df is not None: - self.selected_rows = set(str(i) for i in range(len(df))) + # Use actual DataFrame indices, not range(len(df)) + self.selected_rows = set(str(i) for i in df.index) # Refresh checkbox display self._refresh_table_checkboxes(table_id, self.selected_rows) self.app.notify(f"Selected all {len(self.selected_rows)} rows", timeout=1) @@ -1621,6 +2506,7 @@ class PolicyPrepWorkflowScreen(Screen): table_id = None if self.current_path_review_type == "paths": table_id = "paths_review_table" + # For paths, the combined DataFrame uses ignore_index=True, so indices are 0..n-1 total = 0 if self.primary_paths_df is not None: total += len(self.primary_paths_df) @@ -1632,9 +2518,8 @@ class PolicyPrepWorkflowScreen(Screen): and self.publishers_df is not None ): table_id = "publishers_review_table" - self.selected_path_rows = set( - str(i) for i in range(len(self.publishers_df)) - ) + # For publishers, use actual DataFrame indices + self.selected_path_rows = set(str(i) for i in self.publishers_df.index) # Refresh checkbox display if table_id: @@ -1703,6 +2588,47 @@ class PolicyPrepWorkflowScreen(Screen): self.needs_review_df.to_csv(filepath, index=False) self.app.notify(f"Exported to: {filepath}", severity="information") + def on_key(self, event) -> None: + """Handle keyboard shortcuts including range selection mode.""" + key = event.key + + # Track arrow key navigation to prevent selection + if key in ["up", "down", "left", "right", "pageup", "pagedown", "home", "end"]: + self._keyboard_navigation = True + return # Let the event propagate for navigation + + # 'r' activates range selection mode + if key == "r": + if self.last_clicked_row and self.last_clicked_table: + self._range_mode = True + self.app.notify( + "Range mode: Click end row (or press ESC to cancel)", + severity="information", + timeout=5, + ) + logger.info( + f"RANGE MODE ACTIVATED: starting from row {self.last_clicked_row} in table {self.last_clicked_table}" + ) + else: + self.app.notify( + "Click a row first, then press 'r' to start range selection", + severity="warning", + timeout=3, + ) + + # ESC cancels range mode + elif key == "escape": + if self._range_mode: + self._range_mode = False + self.app.notify( + "Range mode cancelled", severity="information", timeout=2 + ) + logger.info("RANGE MODE CANCELLED") + + def on_key_up(self, event) -> None: + """Handle key releases (currently unused but kept for future).""" + pass + def _export_path_review_data(self) -> None: """Export current path review data to CSV.""" if not self.source_policies: @@ -1792,10 +2718,6 @@ class PolicyPrepWorkflowScreen(Screen): """Open the working directory.""" self._open_folder(self.working_dir) - def action_refresh(self) -> None: - """Refresh the current view.""" - self._update_checklist() - def action_delete_rows(self) -> None: """Delete selected rows in the current table.""" if self.workflow_stage == "first_review": @@ -1818,5 +2740,78 @@ class PolicyPrepWorkflowScreen(Screen): self._select_none_path_rows() def action_toggle_selection(self) -> None: - """Toggle selection on the current row (handled by on_data_table_row_selected).""" - pass # This is handled directly in the DataTable event + """Toggle selection on the current row at cursor position.""" + # Get the focused widget (should be a DataTable) + focused = self.app.focused + + if not isinstance(focused, DataTable): + return + + table = focused + + # Get the current cursor row + try: + cursor_row = table.cursor_row + # Get the row key at the cursor position + row_keys = list(table.rows.keys()) + if cursor_row < len(row_keys): + row_key = str( + row_keys[cursor_row].value + if hasattr(row_keys[cursor_row], "value") + else row_keys[cursor_row] + ) + + logger.info(f"SPACE: Toggling row {row_key} in table {table.id}") + + # Toggle based on table type + if table.id == "source_policy_table": + if row_key in self.selected_source_policy_ids: + self.selected_source_policy_ids.remove(row_key) + else: + self.selected_source_policy_ids.add(row_key) + self._refresh_table_checkboxes( + table.id, self.selected_source_policy_ids + ) + self.app.notify( + f"Selected {len(self.selected_source_policy_ids)} policies", + timeout=1, + ) + + # Remember for range mode + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + elif table.id in ["approved_review_table", "needs_review_table"]: + if row_key in self.selected_rows: + self.selected_rows.remove(row_key) + else: + self.selected_rows.add(row_key) + self._refresh_table_checkboxes(table.id, self.selected_rows) + self.app.notify( + f"Selected {len(self.selected_rows)} rows", timeout=1 + ) + + # Remember for range mode + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + elif table.id in [ + "paths_review_table", + "publishers_review_table", + "remaining_review_table", + ]: + if row_key in self.selected_path_rows: + self.selected_path_rows.remove(row_key) + else: + self.selected_path_rows.add(row_key) + self._refresh_table_checkboxes(table.id, self.selected_path_rows) + self.app.notify( + f"Selected {len(self.selected_path_rows)} items", timeout=1 + ) + + # Remember for range mode + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + except Exception as e: + logger.error(f"Error toggling selection: {e}") diff --git a/TUI/Widgets/prepPolicy.py b/TUI/Widgets/prepPolicy.py index f3448a0..87e2a41 100644 --- a/TUI/Widgets/prepPolicy.py +++ b/TUI/Widgets/prepPolicy.py @@ -88,9 +88,9 @@ def sortHashes( ): working_dir = load_env("WORKING_DIR") history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1–150): ", + prompt="Enter how many days of history to pull (1-365): ", value_type=int, - valid_range=(1, 150), + valid_range=(1, 365), ) logger.debug(f"{history_days} day selected for history") @@ -655,7 +655,7 @@ def section_header(title): def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist): working_dir = load_env("WORKING_DIR") - section_header("Prepare to Enforce Policy ") + section_header("Prepare to Enforce Policy") print( colorText( "\nSequentially follow these steps to prepare a policy for enforcement:", @@ -670,11 +670,11 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all ) ) if not selected_policies: - print(colorText(" [βœ—] No policies have been chosen", "red")) + print(colorText(" [ҝŒ] No policies have been chosen", "red")) else: print(colorText("The following policies have been chosen:", "green")) for policy in selected_policies: - print(colorText(f" [βœ“] {policy.name}", "green")) + print(colorText(f" [Òœ…] {policy.name}", "green")) # Step 2: Destination Policy and Allowlist print( @@ -683,22 +683,22 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all if destination_policy: print( colorText( - f" [βœ“] {destination_policy[0].name} has been selected as the destination policy", + f" [Òœ…] {destination_policy[0].name} has been selected as the destination policy", "green", ) ) else: - print(colorText(" [βœ—] No destination policy has been chosen", "red")) + print(colorText(" [ҝŒ] No destination policy has been chosen", "red")) if destination_allowlist: print( colorText( - f" [βœ“] {destination_allowlist[0].name} has been selected as allowlist", + f" [Òœ…] {destination_allowlist[0].name} has been selected as allowlist", "green", ) ) else: - print(colorText(" [βœ—] No allowlist has been chosen", "red")) + print(colorText(" [ҝŒ] No allowlist has been chosen", "red")) # Step 3: Data Preparation print( @@ -713,9 +713,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [βœ“] Data has been fetched" + " [Òœ…] Data has been fetched" if os.path.exists(review_path) - else " [βœ—] Data has not been fetched" + else " [ҝŒ] Data has not been fetched" ), "green" if os.path.exists(review_path) else "red", ) @@ -723,7 +723,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [βœ—] No policies selected, cannot check data fetch status", "red" + " [ҝŒ] No policies selected, cannot check data fetch status", "red" ) ) @@ -756,9 +756,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [βœ“] Reviewed hashes have been loaded" + " [Òœ…] Reviewed hashes have been loaded" if os.path.exists(approved_path) - else " [βœ—] Reviewed hashes have not been loaded" + else " [ҝŒ] Reviewed hashes have not been loaded" ), "green" if os.path.exists(approved_path) else "red", ) @@ -766,9 +766,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [βœ“] Path review list created" + " [Òœ…] Path review list created" if os.path.exists(second_review_path) - else " [βœ—] Path review list has not been created" + else " [ҝŒ] Path review list has not been created" ), "green" if os.path.exists(second_review_path) else "red", ) @@ -776,7 +776,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [βœ—] No policies selected, cannot check reviewed hashes or path list", + " [ҝŒ] No policies selected, cannot check reviewed hashes or path list", "red", ) ) @@ -812,9 +812,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [βœ“] Reviewed path list detected" + " [Òœ…] Reviewed path list detected" if os.path.exists(reviewed_path) - else " [βœ—] Path review list has not been detected" + else " [ҝŒ] Path review list has not been detected" ), "green" if os.path.exists(reviewed_path) else "red", ) @@ -825,9 +825,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [βœ“] Preflight Path Exclusion List has been generated" + " [Òœ…] Preflight Path Exclusion List has been generated" if preflight_ready - else " [βœ—] Preflight Path Exclusion List has not been generated" + else " [ҝŒ] Preflight Path Exclusion List has not been generated" ), "green" if preflight_ready else "red", ) @@ -835,7 +835,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [βœ—] No policies selected, cannot check preflight status", "red" + " [ҝŒ] No policies selected, cannot check preflight status", "red" ) ) @@ -866,5 +866,5 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print(colorText(" Apply approved hashes to allowlist", "cyan")) # Utility Options - print(colorText("F. Open Working Directory", "cyan")) - print(colorText("B. Back", "cyan")) + print(colorText("F. Open Working Directory", "cyan")) + print(colorText("B. Back", "cyan")) diff --git a/services/agenthandler.py b/services/agenthandler.py index 6d6d250..2d50f4d 100644 --- a/services/agenthandler.py +++ b/services/agenthandler.py @@ -38,9 +38,9 @@ logger = logging.getLogger(__name__) def devicehistory(api: AirlockAPIWrapper, outputjson: bool): agents = selectAgents(api) history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1–150): ", + prompt="Enter how many days of history to pull (1Γ’β‚¬β€œ365): ", value_type=int, - valid_range=(1, 150), + valid_range=(1, 365), ) if not agents or not history_days: @@ -60,7 +60,7 @@ def devicehistory(api: AirlockAPIWrapper, outputjson: bool): except Exception as e: print( colorText( - f"❌ Error retrieving history for {agent.hostname}: {e}", "red" + f"ҝŒ Error retrieving history for {agent.hostname}: {e}", "red" ) ) continue @@ -139,7 +139,7 @@ def findAgents(api, return_dataframe): print( colorText( - f"\nβœ“ Matched devices exported to: {working_dir}\\{filename}", + f"\nΓ’Ε“β€œ Matched devices exported to: {working_dir}\\{filename}", "green", ) ) @@ -148,7 +148,7 @@ def findAgents(api, return_dataframe): def collect_device_names() -> List[str]: - print(colorText("πŸ–₯��Â Device Search", "cyan")) + print(colorText("ðŸ–Β₯Γ―ΒΏΒ½Γ―ΒΏΒ½Γ‚ΒΓƒβ€šΓ‚Β Device Search", "cyan")) print( colorText( "Enter the device hostnames you'd like to search for, one per line.", "cyan" @@ -185,7 +185,7 @@ def collect_device_names() -> List[str]: else: print( colorText( - f"⚠️ Invalid input: '{stripped_line}' — only letters, numbers, underscores, spaces, and hyphens are allowed.", + f"Òő ï¸ Invalid input: '{stripped_line}' Ò€” only letters, numbers, underscores, spaces, and hyphens are allowed.", "yellow", ) ) @@ -235,8 +235,8 @@ def show_unmatched( ] if unmatched: - logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}") - print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow")) + logger.debug(f"Òő ï¸ No matches for: {', '.join(unmatched)}") + print(colorText(f"Òő ï¸ No matches for: {', '.join(unmatched)}", "yellow")) def enrich_agents(agents: List["Agent"], policies: List["Policy"]): @@ -248,7 +248,7 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: device_names = collect_device_names() if not device_names: logger.debug("No device names entered") - print(colorText("⚠️ No device names entered.", "red")) + print(colorText("Òő ï¸ No device names entered.", "red")) return [] use_exact = choose_match_type() @@ -261,11 +261,11 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: show_unmatched(device_names, matched_agents, use_exact) if not matched_agents: - logger.debug("❌ No matching devices found.") - print(colorText("❌ No matching devices found.", "red")) + logger.debug("ҝŒ No matching devices found.") + print(colorText("ҝŒ No matching devices found.", "red")) return [] - print(colorText(f"βœ“ Found {len(matched_agents)} matching device(s).", "green")) + print(colorText(f"Γ’Ε“β€œ Found {len(matched_agents)} matching device(s).", "green")) logger.info("Matched agent hostnames:") rows = (len(matched_agents) + 2) // 3 # 3 columns for row in range(rows): @@ -283,8 +283,8 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: ) if not matched_agents: - logger.debug("❌ No matching devices remain after refinement.") - print(colorText("❌ No matching devices remain after refinement.", "red")) + logger.debug("ҝŒ No matching devices remain after refinement.") + print(colorText("ҝŒ No matching devices remain after refinement.", "red")) return [] enrich_agents(matched_agents, policies) @@ -302,7 +302,7 @@ def moveAgentToRelatedPolicy( Args: api: AirlockAPIWrapper instance. agent: Agent object. - policy_relationship_map: Dict mapping enforcement Ò–€ –€ℒ audit. + policy_relationship_map: Dict mapping enforcement ΓƒΖ’Γ‚Β’Γ’β‚¬β€œΓ’β€šΒ¬Γ‚Β Γ’β‚¬β€œΓ’β€šΒ¬Γ’β€žΒ’ audit. mode: 'audit' to move to audit, 'enforcement' to move to enforcement. """ policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") -- 2.34.1 From 630e0a3cdf3ab70c539a2902c415a79034514e25 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Mon, 15 Dec 2025 14:12:03 -0500 Subject: [PATCH 19/25] Bug Fixes Security Vulnerabilities Patched RUSTSEC-2025-0009 - Some AES functions may panic when overflow checking is enabled RUSTSEC-2024-0336 - rustls::Connection::Common::complete_io could fall into an infinite loop based on network input --- airlock_libs/Cargo.lock | 1239 ++++++++++------------------------ airlock_libs/Cargo.toml | 24 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/prelude.rs | 12 +- airlock_libs/src/services.rs | 69 +- requirements.txt | 2 +- 6 files changed, 413 insertions(+), 935 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index e02d86e..2f841d6 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -24,33 +24,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "airlock_libs" -version = "5.2.1" -dependencies = [ - "chrono", - "crossbeam", - "flexi_logger", - "indicatif", - "log", - "mongodb", - "opentelemetry 0.18.0", - "opentelemetry-otlp", - "opentelemetry-proto", - "opentelemetry-semantic-conventions", - "pyo3", - "pyo3-async-runtimes", - "reqwest", - "serde", - "serde-pyobject", - "serde_json", - "tokio", - "tonic", - "tracing", - "tracing-opentelemetry", - "tracing-subscriber", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -66,150 +39,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-global-executor" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" -dependencies = [ - "async-channel 2.5.0", - "async-executor", - "async-io", - "async-lock", - "blocking", - "futures-lite", - "once_cell", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.2", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" -dependencies = [ - "event-listener 5.4.1", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel 2.5.0", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener 5.4.1", - "futures-lite", - "rustix 1.1.2", -] - -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.2", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-std" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" -dependencies = [ - "async-channel 1.9.0", - "async-global-executor", - "async-io", - "async-lock", - "async-process", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite", - "gloo-timers", - "kv-log-macro", - "log", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - [[package]] name = "async-stream" version = "0.3.6" @@ -229,15 +58,9 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.89" @@ -246,7 +69,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -263,18 +86,17 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.6.20" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", - "bitflags 1.3.2", "bytes", "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", + "http", + "http-body", + "http-body-util", "itoa", "matchit", "memchr", @@ -283,53 +105,38 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", - "sync_wrapper 0.1.2", - "tower 0.4.13", + "sync_wrapper", + "tower 0.5.2", "tower-layer", "tower-service", ] [[package]] name = "axum-core" -version = "0.3.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", "bytes", "futures-util", - "http 0.2.12", - "http-body 0.4.6", + "http", + "http-body", + "http-body-util", "mime", + "pin-project-lite", "rustversion", + "sync_wrapper", "tower-layer", "tower-service", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.10.0" @@ -357,19 +164,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel 2.5.0", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "bson" version = "2.15.0" @@ -377,7 +171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" dependencies = [ "ahash", - "base64 0.22.1", + "base64", "bitvec", "getrandom 0.2.16", "getrandom 0.3.4", @@ -407,9 +201,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.48" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -421,6 +215,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -434,15 +234,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "console" version = "0.16.1" @@ -476,6 +267,15 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -486,6 +286,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -600,7 +410,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.111", + "syn", ] [[package]] @@ -611,20 +421,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.111", -] - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", + "syn", ] [[package]] @@ -650,7 +447,7 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -661,27 +458,29 @@ checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ + "convert_case", "proc-macro2", "quote", - "syn 2.0.111", + "rustc_version", + "syn", "unicode-xid", ] @@ -704,7 +503,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -734,10 +533,10 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -756,33 +555,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener 5.4.1", - "pin-project-lite", -] - [[package]] name = "fastrand" version = "2.3.0" @@ -795,12 +567,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flexi_logger" version = "0.31.7" @@ -850,21 +616,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -898,19 +649,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - [[package]] name = "futures-macro" version = "0.3.31" @@ -919,7 +657,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -940,7 +678,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -990,35 +727,10 @@ dependencies = [ ] [[package]] -name = "gloo-timers" -version = "0.3.0" +name = "glob" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.12.1", - "slab", - "tokio", - "tokio-util", - "tracing", -] +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" @@ -1031,7 +743,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http", "indexmap 2.12.1", "slab", "tokio", @@ -1045,36 +757,18 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -1098,7 +792,7 @@ dependencies = [ "ipnet", "once_cell", "rand 0.9.2", - "ring 0.17.14", + "ring", "thiserror 2.0.17", "tinyvec", "tokio", @@ -1136,26 +830,6 @@ dependencies = [ "digest", ] -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.0" @@ -1166,17 +840,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -1184,7 +847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http", ] [[package]] @@ -1195,8 +858,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] @@ -1212,30 +875,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.8.1" @@ -1246,10 +885,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.12", - "http 1.4.0", - "http-body 1.0.1", + "h2", + "http", + "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1264,26 +904,29 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.4.0", - "hyper 1.8.1", + "http", + "hyper", "hyper-util", - "rustls 0.23.35", + "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] name = "hyper-timeout" -version = "0.4.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 0.14.32", + "hyper", + "hyper-util", "pin-project-lite", "tokio", - "tokio-io-timeout", + "tower-service", ] [[package]] @@ -1294,7 +937,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "native-tls", "tokio", @@ -1304,18 +947,18 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.8.1", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", @@ -1400,9 +1043,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1414,9 +1057,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1532,9 +1175,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.10.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -1555,15 +1198,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1576,12 +1210,6 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1608,9 +1236,12 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -dependencies = [ - "value-bag", -] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "macro_magic" @@ -1621,7 +1252,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1635,7 +1266,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1646,7 +1277,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1657,7 +1288,7 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1699,9 +1330,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -1750,8 +1381,8 @@ version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f5c20217413bed97c613714e6d6dfe39ef59dd79a68999f1043b0566192975" dependencies = [ - "base64 0.22.1", - "bitflags 2.10.0", + "base64", + "bitflags", "bson", "derive-where", "derive_more", @@ -1770,7 +1401,7 @@ dependencies = [ "percent-encoding", "rand 0.9.2", "rustc_version_runtime", - "rustls 0.23.35", + "rustls", "rustversion", "serde", "serde_bytes", @@ -1783,7 +1414,7 @@ dependencies = [ "take_mut", "thiserror 2.0.17", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "typed-builder", "uuid", @@ -1799,15 +1430,9 @@ dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] -[[package]] -name = "multimap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" - [[package]] name = "native-tls" version = "0.2.14" @@ -1820,7 +1445,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -1865,7 +1490,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -1882,7 +1507,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1905,12 +1530,16 @@ dependencies = [ [[package]] name = "opentelemetry" -version = "0.18.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d6c3d7288a106c0a363e4b0e8d308058d56902adefb16f4936f417ffef086e" +checksum = "ab70038c28ed37b97d8ed414b6429d343a8bbf44c9f79ec854f3a643029ba6d7" dependencies = [ - "opentelemetry_api", - "opentelemetry_sdk 0.18.0", + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 1.0.69", + "tracing", ] [[package]] @@ -1927,82 +1556,88 @@ dependencies = [ ] [[package]] -name = "opentelemetry-otlp" -version = "0.11.0" +name = "opentelemetry-appender-log" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c928609d087790fc936a1067bdc310ae702bdf3b090c3f281b713622c8bbde" +checksum = "892c3a3fe0cf009cc180366347bfa134c662f028039bce8aa35994830c68c84f" +dependencies = [ + "log", + "opentelemetry 0.27.1", +] + +[[package]] +name = "opentelemetry-http" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a8a7f5f6ba7c1b286c2fbca0454eaba116f63bbe69ed250b642d36fbb04d80" dependencies = [ "async-trait", - "futures", - "futures-util", - "http 0.2.12", - "opentelemetry 0.18.0", + "bytes", + "http", + "opentelemetry 0.27.1", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cf61a1868dacc576bf2b2a1c3e9ab150af7272909e80085c3173384fe11f76" +dependencies = [ + "async-trait", + "futures-core", + "http", + "opentelemetry 0.27.1", + "opentelemetry-http", "opentelemetry-proto", + "opentelemetry_sdk 0.27.1", "prost", + "reqwest", "thiserror 1.0.69", "tokio", "tonic", + "tracing", ] [[package]] name = "opentelemetry-proto" -version = "0.1.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d61a2f56df5574508dd86aaca016c917489e589ece4141df1b5e349af8d66c28" +checksum = "a6e05acbfada5ec79023c85368af14abd0b307c015e9064d249b2a950ef459a6" dependencies = [ - "futures", - "futures-util", - "opentelemetry 0.18.0", + "hex", + "opentelemetry 0.27.1", + "opentelemetry_sdk 0.27.1", "prost", + "serde", "tonic", - "tonic-build", ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.10.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b02e0230abb0ab6636d18e2ba8fa02903ea63772281340ccac18e0af3ec9eeb" -dependencies = [ - "opentelemetry 0.18.0", -] - -[[package]] -name = "opentelemetry_api" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c24f96e21e7acc813c7a8394ee94978929db2bcc46cf6b5014fc612bf7760c22" -dependencies = [ - "fnv", - "futures-channel", - "futures-util", - "indexmap 1.9.3", - "js-sys", - "once_cell", - "pin-project-lite", - "thiserror 1.0.69", -] +checksum = "bc1b6902ff63b32ef6c489e8048c5e253e2e4a803ea3ea7e783914536eb15c52" [[package]] name = "opentelemetry_sdk" -version = "0.18.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ca41c4933371b61c2a2f214bf16931499af4ec90543604ec828f7a625c09113" +checksum = "231e9d6ceef9b0b2546ddf52335785ce41252bc7474ee8ba05bfad277be13ab8" dependencies = [ "async-trait", - "crossbeam-channel", - "dashmap", - "fnv", "futures-channel", "futures-executor", "futures-util", - "once_cell", - "opentelemetry_api", + "glob", + "opentelemetry 0.27.1", "percent-encoding", "rand 0.8.5", + "serde_json", "thiserror 1.0.69", "tokio", "tokio-stream", + "tracing", ] [[package]] @@ -2020,12 +1655,6 @@ dependencies = [ "thiserror 2.0.17", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -2064,16 +1693,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset", - "indexmap 2.12.1", -] - [[package]] name = "pin-project" version = "1.1.10" @@ -2091,7 +1710,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2106,37 +1725,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "piper" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.2", - "windows-sys 0.61.2", -] - [[package]] name = "portable-atomic" version = "1.11.1" @@ -2167,16 +1761,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" -dependencies = [ - "proc-macro2", - "syn 1.0.109", -] - [[package]] name = "proc-macro2" version = "1.0.103" @@ -2188,56 +1772,25 @@ dependencies = [ [[package]] name = "prost" -version = "0.11.9" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", "prost-derive", ] -[[package]] -name = "prost-build" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" -dependencies = [ - "bytes", - "heck 0.4.1", - "itertools", - "lazy_static", - "log", - "multimap", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn 1.0.109", - "tempfile", - "which", -] - [[package]] name = "prost-derive" -version = "0.11.9" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 1.0.109", -] - -[[package]] -name = "prost-types" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" -dependencies = [ - "prost", + "syn", ] [[package]] @@ -2257,20 +1810,6 @@ dependencies = [ "unindent", ] -[[package]] -name = "pyo3-async-runtimes" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ddb5b570751e93cc6777e81fee8087e59cd53b5043292f2a6d59d5bd80fdfd" -dependencies = [ - "async-std", - "futures", - "once_cell", - "pin-project-lite", - "pyo3", - "tokio", -] - [[package]] name = "pyo3-build-config" version = "0.27.2" @@ -2300,7 +1839,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2309,11 +1848,11 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "pyo3-build-config", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2325,6 +1864,61 @@ dependencies = [ "cc", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -2411,7 +2005,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags", ] [[package]] @@ -2445,19 +2039,21 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", - "h2 0.4.12", - "http 1.4.0", - "http-body 1.0.1", + "futures-util", + "h2", + "http", + "http-body", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-rustls", "hyper-tls", "hyper-util", @@ -2467,13 +2063,17 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower 0.5.2", "tower-http", "tower-service", @@ -2481,6 +2081,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -2489,21 +2090,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - [[package]] name = "ring" version = "0.17.14" @@ -2514,10 +2100,16 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2537,44 +2129,19 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" -dependencies = [ - "log", - "ring 0.16.20", - "sct", - "webpki", -] - [[package]] name = "rustls" version = "0.23.35" @@ -2583,7 +2150,7 @@ checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "log", "once_cell", - "ring 0.17.14", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2592,23 +2159,23 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.6.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" dependencies = [ "openssl-probe", - "rustls-pemfile", + "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] name = "rustls-pemfile" -version = "1.0.4" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" dependencies = [ - "base64 0.21.7", + "rustls-pki-types", ] [[package]] @@ -2617,6 +2184,7 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ + "web-time", "zeroize", ] @@ -2626,9 +2194,9 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ - "ring 0.17.14", + "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -2658,24 +2226,27 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2745,7 +2316,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2793,7 +2364,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2842,6 +2413,34 @@ dependencies = [ "libc", ] +[[package]] +name = "signoz_test" +version = "6.0.0" +dependencies = [ + "chrono", + "crossbeam", + "flexi_logger", + "indicatif", + "log", + "mongodb", + "opentelemetry 0.27.1", + "opentelemetry-appender-log", + "opentelemetry-otlp", + "opentelemetry-proto", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk 0.27.1", + "pyo3", + "reqwest", + "serde", + "serde-pyobject", + "serde_json", + "tokio", + "tonic", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", +] + [[package]] name = "slab" version = "0.4.11" @@ -2874,12 +2473,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2909,17 +2502,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.111" @@ -2931,12 +2513,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2954,7 +2530,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2963,8 +2539,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3011,7 +2587,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.2", + "rustix", "windows-sys 0.61.2", ] @@ -3041,7 +2617,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3052,7 +2628,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3146,16 +2722,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tokio-io-timeout" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" -dependencies = [ - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-macros" version = "2.6.0" @@ -3164,7 +2730,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3177,24 +2743,13 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" -dependencies = [ - "rustls 0.20.9", - "tokio", - "webpki", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.35", + "rustls", "tokio", ] @@ -3225,50 +2780,35 @@ dependencies = [ [[package]] name = "tonic" -version = "0.8.3" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f219fad3b929bef19b1f86fbc0358d35daed8f2cac972037ac0dc10bbb8d5fb" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" dependencies = [ "async-stream", "async-trait", "axum", - "base64 0.13.1", + "base64", "bytes", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", "hyper-timeout", + "hyper-util", "percent-encoding", "pin-project", "prost", - "prost-derive", "rustls-native-certs", "rustls-pemfile", + "socket2 0.5.10", "tokio", - "tokio-rustls 0.23.4", + "tokio-rustls", "tokio-stream", - "tokio-util", "tower 0.4.13", "tower-layer", "tower-service", "tracing", - "tracing-futures", -] - -[[package]] -name = "tonic-build" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bf5e9b9c0f7e0a7c027dcfaba7b2c60816c7049171f679d99ee2ff65d0de8c4" -dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build", - "quote", - "syn 1.0.109", ] [[package]] @@ -3300,7 +2840,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -3308,15 +2848,15 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "iri-string", "pin-project-lite", "tower 0.5.2", @@ -3355,7 +2895,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3368,16 +2908,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-futures" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" -dependencies = [ - "pin-project", - "tracing", -] - [[package]] name = "tracing-log" version = "0.2.0" @@ -3445,7 +2975,7 @@ checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3481,6 +3011,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.2" @@ -3505,12 +3041,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" @@ -3553,12 +3083,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "value-bag" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" - [[package]] name = "vcpkg" version = "0.2.15" @@ -3640,7 +3164,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn", "wasm-bindgen-shared", ] @@ -3673,16 +3197,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - [[package]] name = "webpki-roots" version = "1.0.4" @@ -3692,46 +3206,12 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - [[package]] name = "widestring" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -3753,7 +3233,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3764,7 +3244,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3820,15 +3300,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -4083,7 +3554,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", "synstructure", ] @@ -4104,7 +3575,7 @@ checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -4124,7 +3595,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", "synstructure", ] @@ -4164,5 +3635,5 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 040ecf4..5908dc9 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,33 +1,31 @@ [package] -name = "airlock_libs" -version = "5.2.1" +name = "signoz_test" +version = "6.0.0" edition = "2024" -[lib] -crate-type = ["cdylib"] - [dependencies] chrono = "0.4.42" indicatif = "0.18.2" mongodb = "3.3.0" -opentelemetry = { version = "0.18.0", features = ["rt-tokio", "metrics", "trace"] } -opentelemetry-otlp = { version = "0.11.0", features = ["trace", "metrics"] } -opentelemetry-semantic-conventions = { version = "0.10.0" } -opentelemetry-proto = { version = "0.1.0"} +opentelemetry = { version = "0.27.0", features = ["logs", "metrics", "trace"] } +opentelemetry-otlp = { version = "0.27.0", features = ["trace", "metrics", "grpc-tonic", "http-proto", "tls", "reqwest-client", "reqwest-rustls"] } +opentelemetry-semantic-conventions = { version = "0.27.0" } +opentelemetry-proto = { version = "0.27.0"} pyo3 = { version = "0.27.0", features = ["extension-module", "generate-import-lib"] } -reqwest = { version = "0.12.24", features = ["json", "native-tls"] } +reqwest = { version = "0.12.24", features = ["json", "native-tls", "rustls-tls"] } serde = "1.0.228" serde-pyobject = "0.8.0" serde_json = "1.0.145" tokio = { version = "1.48.0", features = ["full"] } -tonic = { version = "0.8.2", features = ["tls-roots"] } +tonic = { version = "0.12.3", features = ["tls-roots"] } tracing = "0.1.41" tracing-subscriber = "0.3.20" tracing-opentelemetry = "0.32.0" -pyo3-async-runtimes = { version = "0.27.0", features = ["async-std", "tokio"] } crossbeam = "0.8.4" log = "0.4.29" flexi_logger = "0.31.7" +opentelemetry-appender-log = "0.27.0" +opentelemetry_sdk = { version = "0.27.0", features = ["rt-tokio", "trace"] } [package.metadata.maturin] generate-abi-stubs = true @@ -40,4 +38,4 @@ codegen-units = 1 panic = 'abort' strip = true debug-assertions = false -overflow-checks = false +overflow-checks = true diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 2aa44c8..763803b 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "5.2.1" +version = "6.0.0" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/prelude.rs b/airlock_libs/src/prelude.rs index 8d87356..e2ffa2e 100644 --- a/airlock_libs/src/prelude.rs +++ b/airlock_libs/src/prelude.rs @@ -1,15 +1,15 @@ pub use chrono::{Duration, Local, NaiveDate}; pub use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; pub use mongodb::bson::oid::ObjectId; -pub use opentelemetry::global::shutdown_tracer_provider; -pub use opentelemetry::sdk::Resource; +pub use opentelemetry::global::GlobalTracerProvider; pub use opentelemetry::trace::noop::NoopTracerProvider; -pub use opentelemetry::trace::{Status, TraceContextExt, TraceError}; -pub use opentelemetry::{Context, KeyValue, sdk::trace as sdktrace, trace::Tracer}; -pub use opentelemetry::{Key, global}; +pub use opentelemetry::trace::{Status, TraceContextExt, Tracer}; +pub use opentelemetry::*; +pub use opentelemetry_otlp::ExportConfig; pub use opentelemetry_otlp::WithExportConfig; +pub use opentelemetry_sdk::Resource; +pub use opentelemetry_sdk::trace::{Config, TracerProvider}; pub use pyo3::{prelude::*, types::PyString}; -pub use pyo3_async_runtimes::async_std; pub use reqwest::{ Client, header::{HeaderMap, HeaderName, HeaderValue}, diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index d54ceed..6a9f3a9 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -1,8 +1,10 @@ use crate::modules::datatypes::*; use crate::prelude::*; use crossbeam::channel::unbounded; +use opentelemetry_otlp::WithTonicConfig; use std::sync::{Arc, Mutex}; use std::thread; +use tonic::transport::{Channel, ClientTlsConfig}; #[pyfunction] pub fn pull_policy_exec_histories( py: Python<'_>, @@ -11,9 +13,10 @@ pub fn pull_policy_exec_histories( exec_types: String, days: i64, ) -> Py { - let data = PyData::extract_data(py, &py_self); - let headers = data.headers; - let base_url = data.base_url; + println!(); + let data: PyData = PyData::extract_data(py, &py_self); + let headers: HeaderMap = data.headers; + let base_url: String = data.base_url; let handle: thread::JoinHandle = std::thread::spawn(move || { let rt: tokio::runtime::Runtime = match tokio::runtime::Runtime::new() { Ok(rt) => rt, @@ -22,10 +25,9 @@ pub fn pull_policy_exec_histories( std::process::abort(); } }; - rt.block_on(async { - let _ = init_tracer(); - }); - let tracer: global::BoxedTracer = global::tracer("global_tracer"); + let tracer_provider = rt.block_on(async { init_tracer() }); + global::set_tracer_provider(tracer_provider.clone()); + let tracer: global::BoxedTracer = global::tracer("tracer"); let _cx: Context = Context::new(); let file_path: PathBuf = format!( "{}\\cache\\chunkinator.json", @@ -106,7 +108,8 @@ pub fn pull_policy_exec_histories( } } }); - let cutoff: chrono::NaiveDateTime = Local::now().naive_local() - Duration::days(days); + let cutoff: chrono::NaiveDateTime = + Local::now().naive_local() - chrono::Duration::days(days); let (tx, rx) = unbounded::>(); let pb_clone = progress_bar.clone(); thread::spawn(move || { @@ -181,7 +184,10 @@ pub fn pull_policy_exec_histories( .unwrap() .enable_steady_tick(std::time::Duration::from_millis(100)); let span: opentelemetry::trace::SpanRef<'_> = cx.span(); - span.set_attribute(Key::new("Days").string(days.to_string())); + //span.set_attribute(Key::new("Days").string(days.to_string())); + //span.set_attribute(Key::new("Days")); + //span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); + span.set_attribute(KeyValue::new("Days", days)); span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); loop { let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { @@ -237,8 +243,10 @@ pub fn pull_policy_exec_histories( std::process::abort(); } }; + tracer_provider + .shutdown() + .expect("Failed to Shutdown Tracer Provdier"); drop(tx); - shutdown_tracer_provider(); return_data.to_string() }); let gil_value: String = handle.join().unwrap(); @@ -313,25 +321,26 @@ pub fn get_base_directory() -> PathBuf { } } -fn init_tracer() -> Result, TraceError> { +fn init_tracer() -> opentelemetry_sdk::trace::TracerProvider { let cfg: TelemetryConfig = TelemetryConfig::load(); - if !cfg.TELEMETRY { - global::set_tracer_provider(NoopTracerProvider::new()); - return Ok(None); - } - let endpoint: String = cfg.TELEM_URL.unwrap_or_default(); - let tracer: sdktrace::Tracer = - opentelemetry_otlp::new_pipeline() - .tracing() - .with_exporter( - opentelemetry_otlp::new_exporter() - .tonic() - .with_endpoint(endpoint), - ) - .with_trace_config(sdktrace::config().with_resource(Resource::new(vec![ - KeyValue::new("service.name", "LoxideLibs"), - ]))) - .install_simple() - .unwrap(); - Ok(Some(tracer)) + let endpoint = cfg.TELEM_URL.unwrap_or_default().clone(); + let channel_endpoint = endpoint.clone(); + let channel = Channel::from_shared(channel_endpoint.clone()) + .unwrap() + .tls_config(ClientTlsConfig::new().with_native_roots()) + .unwrap() + .connect_lazy(); + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_tonic() + .with_endpoint(endpoint.clone()) + .with_channel(channel) + .build() + .expect("Failed to build exporter"); + opentelemetry_sdk::trace::TracerProvider::builder() + .with_simple_exporter(exporter) + .with_resource(Resource::new(vec![KeyValue::new( + "service.name", + "LoxideLibs", + )])) + .build() } diff --git a/requirements.txt b/requirements.txt index 71907d6..6492e2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==5.2.1 \ No newline at end of file +airlock_libs==6.0.0 \ No newline at end of file -- 2.34.1 From 57d0f12000dbc9604a52ad5027844b4d71f8394a Mon Sep 17 00:00:00 2001 From: Zarithas Date: Mon, 15 Dec 2025 17:01:58 -0500 Subject: [PATCH 20/25] feat: Complete Policy Prep Workflow with UX upgrades, Liftoff API, and TUI merge - Added intro screen with workflow overview, time estimate, and onboarding controls - Improved visuals: cleaner checkboxes (/), better loading screen layout - Enforced mandatory tab reviews for critical steps with warnings and blocked navigation - Optimized logging: INFO for milestones, DEBUG for internals; cleaner production logs - Implemented Liftoff API integration: paths, publishers, hashes with granular error handling - Color-coded completion feedback ( success, failure, partial) and detailed summaries - Consolidated architecture: merged TUI.py into Loxide.py (single entry point, no circular imports) - Fixed race condition in table creation with concurrency locks --- Loxide.py | 427 ++++++++++++++++++++++- TUI/Screens/policyprepworkflowscreen.py | 439 +++++++++++++++++++---- TUI/TUI.py | 444 ------------------------ requirements.txt | 22 +- 4 files changed, 820 insertions(+), 512 deletions(-) delete mode 100644 TUI/TUI.py diff --git a/Loxide.py b/Loxide.py index 7c3e974..912ba22 100644 --- a/Loxide.py +++ b/Loxide.py @@ -23,19 +23,438 @@ import logging import os +from typing import Optional +import dotenv +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.message import Message +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import ( + Button, + DirectoryTree, + Footer, + Header, + Static, + Tab, + Tabs, +) import urllib3 +from models.agent import Agent +from models.policy import Policy from services.API import AirlockAPIWrapper from services.security import getAPI -from TUI.TUI import run_Loxide -from utils.configmanager import get_system_value -from utils.setup import setup -from utils.utils import irtang +from TUI.Screens.moveagentworkflowscreen import MoveAgentWorkflowScreen +from TUI.Screens.otpactivityscreen import OTPActivitiesScreen +from TUI.Screens.otprevokescreen import OTPRevokeScreen +from TUI.Screens.otpworkflowscreen import OTPWorkflowScreen +from TUI.Screens.policyprepworkflowscreen import PolicyPrepWorkflowScreen +from TUI.Screens.quietagentworkflowscreen import QuietAgentWorkflowScreen +from TUI.Themes.theme_amber_terminal import get_amber_terminal_theme +from TUI.Themes.theme_retro_terminal import get_retro_terminal_theme +from TUI.Themes.themeselector import ThemeSelector +from TUI.Widgets.agentmoveoperations import AgentMoveOperations +from TUI.Widgets.multiagentselector import MultiAgentSelector +from TUI.Widgets.policytreewidget import PolicyTreeWidget +from TUI.Widgets.resultsdisplay import ResultsDisplay +from utils.configmanager import ( + get_system_value, + get_user_value, + load_env, + save_user_config, +) +from utils.setup import get_base_directory, setup +from utils.utils import irtang, open_directory +dotenv.load_dotenv() urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +# --------------------------------------------------------------------------- +# GLOBAL STASH +# --------------------------------------------------------------------------- +_APP_RESTART_REASON = None + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# helper to persist TEXTUAL_THEME to *user* config and mirror to .env +# --------------------------------------------------------------------------- +def _persist_user_theme(theme_name: str) -> None: + """ + Store the chosen Textual theme in the user's config using the config manager. + No need to touch .env - config manager handles everything. + """ + base_dir = get_base_directory() + config_dir = base_dir / "config" + + try: + save_user_config(config_dir, {"TEXTUAL_THEME": theme_name}) + logger.debug("Updated user config with TEXTUAL_THEME=%s", theme_name) + except Exception as exc: + logger.error("Failed to save TEXTUAL_THEME: %s", exc) + + +# --------------------------------------------------------------------------- +# 1) SCREEN +# --------------------------------------------------------------------------- +class MainMenuScreen(Screen): + api: AirlockAPIWrapper + current_tab = reactive("") + + BUTTON_DEFS = { + "agent_actions": [ + ( + "πŸ–₯️ - Find agent, Move agent, or Generate One Time Pass", + "move_agent_workflow_button", + ), + ("🎫 - Review and approve OTP Activities", "otp_activities_button"), + ("πŸ›‘ - Revoke Active OTP Session", "otp_revoke_button"), + ], + "policy": [ + ("βš–οΈ - Prepare Policy For Enforcement", "policy_prep_button"), + ("πŸ”• - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), + ], + } + + def __init__(self) -> None: + super().__init__() + self.extras = get_user_value("EXTRAS", str, "NOTTODAY") + wd = load_env("WORKING_DIR") or os.getcwd() + if not os.path.isdir(wd): + wd = os.getcwd() + self.working_dir = wd + + def _make_buttons_for(self, tab_id: str) -> Vertical: + defs = self.BUTTON_DEFS.get(tab_id, []) + buttons = [] + for label, btn_id in defs: + btn = Button(label, id=btn_id) + btn.styles.width = "100%" + buttons.append(btn) + return Vertical(*buttons) + + def compose(self) -> ComposeResult: + yield Header(show_clock=True, icon="βš™") + + tabs = [ + Tab("Tree View", id="p_tree"), + Tab("Agents", id="agent_actions"), + Tab("Directory", id="dir"), + Tab("Settings", id="settings"), + ] + + if self.extras == "POLICYPREP": + tabs.insert(2, Tab("Policy Prep", id="policy")) + + yield Tabs(*tabs, id="tabs") + yield Vertical(id="content") + yield Footer() + + def on_mount(self) -> None: + self.switch_tab("agent_actions") + + # focus helpers + def _get_content_buttons(self) -> list[Button]: + content = self.query_one("#content", Vertical) + return list(content.query(Button)) + + def _focus_first_button(self) -> None: + buttons = self._get_content_buttons() + if buttons: + buttons[0].focus() + + def _focus_tabs(self) -> None: + tabs = self.query_one("#tabs", Tabs) + tabs.focus() + + def _focus_nearby_button(self, direction: int) -> None: + buttons = self._get_content_buttons() + if not buttons: + return + + try: + current = next(i for i, b in enumerate(buttons) if b.has_focus) + except StopIteration: + if direction > 0: + buttons[0].focus() + else: + buttons[-1].focus() + return + + if direction < 0 and current == 0: + self._focus_tabs() + return + + new_index = current + direction + if 0 <= new_index < len(buttons): + buttons[new_index].focus() + + def switch_tab(self, tab_id: str) -> None: + self.current_tab = tab_id + content = self.query_one("#content", Vertical) + content.remove_children() + + if tab_id in self.BUTTON_DEFS: + content.mount(self._make_buttons_for(tab_id)) + self.call_later(self._focus_first_button) + elif tab_id == "dir": + content.mount(DirectoryTree(self.working_dir, id="dir_tree")) + elif tab_id == "p_tree": + content.mount(PolicyTreeWidget(self.app.policies, self.app.devices)) + elif tab_id == "settings": + content.mount(ThemeSelector()) + else: + content.mount(Static(f"Unknown tab: {tab_id}")) + + def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + self.switch_tab(event.tab.id) + + def on_multi_agent_selector_agents_selected( + self, message: MultiAgentSelector.AgentsSelected + ) -> None: + """Handle selected agents from AgentSelector.""" + global _APP_RESTART_REASON + selected_agents = message.selected_agents + logger.info("Selected agents: %s", selected_agents) + # TODO: Implement actual handling of selected agents + _APP_RESTART_REASON = ("multi_agent_action", selected_agents) + self.app.exit() + + def on_theme_selector_theme_selected( + self, message: ThemeSelector.ThemeSelected + ) -> None: + """Handle theme selection from ThemeSelector.""" + global _APP_RESTART_REASON + _persist_user_theme(message.theme_name) + _APP_RESTART_REASON = ("restart",) + self.app.exit() + + def on_agent_move_operations_operation_complete( + self, message: AgentMoveOperations.OperationComplete + ) -> None: + """Handle completion of agent move operation - show results.""" + logger.info( + "Agent move operation completed: %s, %d successful, %d unsuccessful", + message.operation, + len(message.successful), + len(message.unsuccessful), + ) + + # Format results for display + successful_text = "\n".join( + [f"{agent.hostname}" for agent, _ in message.successful] + ) + unsuccessful_text = "\n".join( + [f"{agent.hostname}: {error}" for agent, error in message.unsuccessful] + ) + + # Remove the operations widget + try: + ops_widget = self.query_one(AgentMoveOperations) + ops_widget.remove() + except Exception: + pass + + # Show results + self.query_one("#content", Vertical).mount( + ResultsDisplay(message.operation, successful_text, unsuccessful_text) + ) + + def on_results_display_go_back(self, message: ResultsDisplay.GoBack) -> None: + """Handle back button from results display.""" + try: + results_widget = self.query_one(ResultsDisplay) + results_widget.remove() + except Exception: + pass + # Return to main menu + self.app.pop_screen() + + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: + path = event.path + logger.debug("Directory file selected: %s", path) + try: + open_directory(str(path)) + except Exception as exc: + logger.error("Failed to open %s: %s", path, exc) + self.app.bell() + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + logger.debug("Button pressed: %s", button_id) + + match button_id: + case "move_agent_workflow_button": + self.app.push_screen(MoveAgentWorkflowScreen(self.app.devices)) + event.stop() + + case "otp_generate_button": + self.app.push_screen(OTPWorkflowScreen(self.app.devices)) + event.stop() + + case "find_quiet_button": + self.app.push_screen( + QuietAgentWorkflowScreen(self.app.api, self.app.policies) + ) + event.stop() + return + + case "otp_activities_button": + self.app.push_screen(OTPActivitiesScreen()) + event.stop() + return + + case "otp_revoke_button": + self.app.push_screen(OTPRevokeScreen()) + event.stop() + return + + case "policy_prep_button": + # Use the new TUI workflow screen instead of legacy + self.app.push_screen( + PolicyPrepWorkflowScreen(self.app.api, self.app.policies) + ) + event.stop() + return + + case _: + self.app.bell() + logger.warning("Unknown button pressed: %s", button_id) + return + + +# --------------------------------------------------------------------------- +# 2) APP +# --------------------------------------------------------------------------- +class Loxide(App[Message]): + api: AirlockAPIWrapper + working_dir: str + policies: Optional[list[Policy]] + devices: Optional[list[Agent]] + + CSS = """ + #logo { + width: 100%; + content-align: center middle; + text-align: center; + } + """ + BINDINGS = [ + ("q", "quit", "Quit"), + ("f", "open_fe", "Launch Explorer"), + ("r", "refresh", "Refresh"), + ] + + def __init__(self, api: AirlockAPIWrapper): + self._textual_theme = get_user_value("TEXTUAL_THEME", str, "textual-dark") + super().__init__() + self.api = api + wd = load_env("WORKING_DIR") or os.getcwd() + if not os.path.isdir(wd): + wd = os.getcwd() + self.working_dir = wd + # Initial data load + self.refresh_data() + + def refresh_data(self) -> None: + """Public method to refresh policies and devices from the API.""" + try: + self.policies = [ + Policy(**row.to_dict()) + for _, row in self.api.policy_find_all().iterrows() + ] + self.devices = [ + Agent(**row.to_dict()) + for _, row in self.api.agent_find_all().iterrows() + ] + if self.policies and self.devices: + for agent in self.devices: + agent.enrich_with_policies(self.policies) + logger.debug( + f"Enriched {len(self.devices)} agents with policy information" + ) + except Exception as exc: + logger.error("Failed to load policies/devices: %s", exc) + self.policies = None + self.devices = None + + def on_mount(self, api: AirlockAPIWrapper) -> None: + self.register_theme(get_retro_terminal_theme()) + self.register_theme(get_amber_terminal_theme()) + self.theme = self._textual_theme + self.push_screen(MainMenuScreen()) + + def action_refresh(self) -> None: + self.refresh_data() + + def action_quit(self) -> None: + global _APP_RESTART_REASON + _APP_RESTART_REASON = None + self.exit() + + def action_open_fe(self) -> None: + """Open the working directory in the OS file manager (footer binding).""" + path_to_open = self.working_dir or os.getcwd() + try: + open_directory(path_to_open) + except Exception as exc: + logger.error("Failed to open directory %s: %s", path_to_open, exc) + self.bell() # optional feedback + + +# --------------------------------------------------------------------------- +# 3) PUBLIC ENTRYPOINT +# --------------------------------------------------------------------------- +def run_Loxide(api: AirlockAPIWrapper) -> None: + global _APP_RESTART_REASON + base_dir = get_base_directory() + env_path = base_dir / ".env" + dotenv.load_dotenv(dotenv_path=env_path, override=True) + + max_attempts = 5 + attempts = 0 + + while attempts < max_attempts: + attempts += 1 + logger.debug("Starting app loop iteration (attempt %d)", attempts) + _APP_RESTART_REASON = None + app = Loxide(api) + + try: + app.run() + except SystemExit as exc: + if exc.code != 0: + logger.debug("Caught SystemExit from Textual: %s", exc) + raise + + reason = _APP_RESTART_REASON + logger.debug("After app.run(), _APP_RESTART_REASON = %r", reason) + + if not reason: + logger.debug("No restart reason, exiting loop") + break + + if reason[0] == "restart": + logger.debug("Restarting app loop") + continue + + if reason[0] == "multi_agent_action": + logger.info("Multi-agent action with selected agents: %s", reason[1]) + continue + + logger.error("Unknown restart reason: %r", reason) + break + + +# --------------------------------------------------------------------------- +# 4) MAIN FUNCTION +# --------------------------------------------------------------------------- def main(): irtang() # Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored diff --git a/TUI/Screens/policyprepworkflowscreen.py b/TUI/Screens/policyprepworkflowscreen.py index ceec0d6..ec9b03a 100644 --- a/TUI/Screens/policyprepworkflowscreen.py +++ b/TUI/Screens/policyprepworkflowscreen.py @@ -165,6 +165,18 @@ class PolicyPrepWorkflowScreen(Screen): # Track if we're navigating with keyboard (to prevent selection) self._keyboard_navigation = False + # Tab review tracking for Step 5 (First Review) + self.approved_tab_reviewed = False + self.needs_review_tab_reviewed = False + + # Tab review tracking for Step 6 (Path Review) + self.paths_tab_reviewed = False + self.publishers_tab_reviewed = False + + # Lock to prevent concurrent table creation + self._creating_review_table = False + self._creating_path_table = False + def compose(self) -> ComposeResult: """Build the UI layout for the workflow screen.""" yield Header(show_clock=True, icon="βš™οΈ") @@ -191,7 +203,7 @@ class PolicyPrepWorkflowScreen(Screen): def on_mount(self) -> None: """Initialize the screen when mounted.""" self._update_checklist() - self._show_source_policy_selection() + self._show_introduction() def watch_workflow_stage(self, old_value: str, new_value: str) -> None: """React to workflow stage changes.""" @@ -348,6 +360,86 @@ class PolicyPrepWorkflowScreen(Screen): step8.styles.text_style = "dim" col2.mount(step8) + def _show_introduction(self) -> None: + """Show workflow introduction and overview.""" + self.workflow_stage = "introduction" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Title + title = Static("Welcome to Policy Preparation Workflow") + title.styles.margin = (1, 1) + title.styles.text_style = "bold" + title.styles.text_align = "center" + content.mount(title) + + # Description + description = Static( + "This workflow will help you:\n" + " β€’ Fetch execution history from selected policies\n" + " β€’ Review and approve safe executions\n" + " β€’ Calculate efficient path exclusions\n" + " β€’ Generate publisher trust rules\n" + " β€’ Apply changes to your destination policy" + ) + description.styles.margin = (1, 2) + content.mount(description) + + # Process steps + steps_title = Static("The Process:") + steps_title.styles.margin = (1, 2, 0, 2) + steps_title.styles.text_style = "bold" + content.mount(steps_title) + + steps = Static( + " πŸ“‹ Step 1: Select source policies (data collection)\n" + " 🎯 Step 2: Select destination policy (where changes go)\n" + " πŸ“ Step 3: Select destination allowlist\n" + " πŸ“Š Step 4: Fetch execution data (may take 1-2 minutes)\n" + " βœ… Step 5: Review approved/needs review executions\n" + " πŸ“ Step 6: Review path exclusions and publishers\n" + " πŸ” Step 7: Preview changes before applying\n" + " πŸš€ Step 8: Liftoff - Apply to production" + ) + steps.styles.margin = (0, 2) + content.mount(steps) + + # Time estimate + estimate = Static("⏱️ Estimated Time: 15-30 minutes depending on data size") + estimate.styles.margin = (1, 2) + estimate.styles.color = "cyan" + content.mount(estimate) + + # Tips + tips_title = Static("πŸ’‘ Tips:") + tips_title.styles.margin = (1, 2, 0, 2) + tips_title.styles.text_style = "bold" + content.mount(tips_title) + + tips = Static( + " β€’ Start with a test policy first\n" + " β€’ Review carefully - changes affect all agents\n" + " β€’ Use path rules when possible (more efficient)\n" + " β€’ Publishers are powerful - use cautiously" + ) + tips.styles.margin = (0, 2) + tips.styles.color = "yellow" + content.mount(tips) + + # Buttons + button_container = Horizontal() + button_container.styles.margin = (2, 2) + button_container.styles.align = ("center", "middle") + content.mount(button_container) + + continue_btn = Button( + "Continue to Policy Selection", id="start_workflow", variant="success" + ) + cancel_btn = Button("Cancel", id="cancel_workflow", variant="default") + + button_container.mount(continue_btn) + button_container.mount(cancel_btn) + def _show_source_policy_selection(self) -> None: """Show the source policy selection screen.""" self.workflow_stage = "select_source" @@ -366,7 +458,7 @@ class PolicyPrepWorkflowScreen(Screen): table.zebra_stripes = True # Add columns - checkbox first, then data columns - table.add_columns("☐", "Name", "ID", "Parent") + table.add_columns("β—‹", "Name", "ID", "Parent") # Sort policies by name for easier selection sorted_policies = sorted(self.policies, key=lambda p: p.name.lower()) @@ -376,7 +468,7 @@ class PolicyPrepWorkflowScreen(Screen): # Skip parent policies if policy.parent == "global-policy-settings": continue - checkbox = "☐" # All start unchecked + checkbox = "β—‹" # All start unchecked table.add_row( checkbox, policy.name, @@ -739,7 +831,7 @@ class PolicyPrepWorkflowScreen(Screen): def _show_fetch_results(self) -> None: """Show the results of data fetching.""" - logger.info("=== _show_fetch_results called ===") + logger.debug("=== _show_fetch_results called ===") self.workflow_stage = "first_review" content = self.query_one("#content_area", Vertical) content.remove_children() @@ -792,15 +884,39 @@ class PolicyPrepWorkflowScreen(Screen): logger.info("Mounted tab buttons") # Show approved table by default - logger.info("About to call _show_review_table('approved')") + logger.debug("About to call _show_review_table('approved')") self._show_review_table("approved") - logger.info("=== _show_fetch_results complete ===") + logger.debug("=== _show_fetch_results complete ===") def _show_review_table(self, table_type: str) -> None: """Show an editable DataTable for reviewing executions.""" - logger.info(f"=== _show_review_table called with type: {table_type} ===") + # Prevent concurrent execution + if self._creating_review_table: + logger.warning( + f"Already creating review table, ignoring duplicate call for {table_type}" + ) + return + + self._creating_review_table = True + + try: + self._show_review_table_impl(table_type) + finally: + self._creating_review_table = False + + def _show_review_table_impl(self, table_type: str) -> None: + """Internal implementation of _show_review_table.""" + logger.debug(f"_show_review_table called with type: {table_type}") content = self.query_one("#content_area", Vertical) + # Mark tab as reviewed + if table_type == "approved": + self.approved_tab_reviewed = True + logger.debug("Marked approved tab as reviewed") + else: + self.needs_review_tab_reviewed = True + logger.debug("Marked needs_review tab as reviewed") + # Determine which dataframe and table ID to show if table_type == "approved": df = self.approved_df @@ -841,6 +957,18 @@ class PolicyPrepWorkflowScreen(Screen): except Exception as e: logger.debug(f"Error removing existing tables: {e}") + # Remove existing instruction and help text (they accumulate without removal) + # Remove ALL Static widgets - they're just text that needs to be replaced + try: + existing_statics = content.query("Static") + logger.debug( + f"Found {len(existing_statics)} existing Static widgets to remove" + ) + for static in existing_statics: + static.remove() + except Exception as e: + logger.debug(f"Error removing Static widgets: {e}") + # Force a refresh to ensure removals are processed try: content.refresh() @@ -850,7 +978,7 @@ class PolicyPrepWorkflowScreen(Screen): # Note: We no longer remove review_controls or review_continue_container # They are reused between tabs to avoid DuplicateIds errors - logger.info( + logger.debug( f"DataFrame for {table_type}: {'empty' if df is None or df.empty else f'{len(df)} rows'}" ) @@ -861,13 +989,13 @@ class PolicyPrepWorkflowScreen(Screen): logger.info(f"No data for {table_type}, mounted empty message") return - # Instructions + # Instructions (no ID needed - we remove all Statics anyway) instruction = Static(title) instruction.styles.margin = (1, 1) instruction.styles.text_style = "bold" content.mount(instruction) - # Help text + # Help text (no ID needed) help_text = Static( "Click to toggle, 'r' for range select (click start, press 'r', click end)\n" "Space to toggle cursor row, 'd' to delete, 'a' select all, arrows navigate" @@ -876,6 +1004,19 @@ class PolicyPrepWorkflowScreen(Screen): help_text.styles.text_style = "dim" content.mount(help_text) + # CRITICAL: Check if table already exists in content (should not happen after removal above) + try: + existing_check = content.query_one(f"#{table_id}", DataTable) + if existing_check: + logger.error( + f"Table {table_id} STILL EXISTS after removal! This should not happen." + ) + # Don't create a new one - just return + return + except Exception: + # Good - table doesn't exist, proceed with creation + pass + # Create the review table review_table = DataTable(id=table_id) review_table.styles.height = "40vh" # Increased since we removed button rows @@ -899,15 +1040,15 @@ class PolicyPrepWorkflowScreen(Screen): if available_cols: # Add checkbox column first - review_table.add_columns("☐", *available_cols) + review_table.add_columns("β—‹", *available_cols) # Add rows with row keys for tracking for idx, row in df.iterrows(): - checkbox = "☐" # All start unchecked + checkbox = "β—‹" # All start unchecked row_data = [str(row.get(col, "")) for col in available_cols] review_table.add_row(checkbox, *row_data, key=str(idx)) - logger.info(f"About to mount {table_id}") + logger.debug(f"About to mount {table_id}") # Final safety check - make sure no table with this ID exists before mounting try: @@ -923,7 +1064,7 @@ class PolicyPrepWorkflowScreen(Screen): pass content.mount(review_table) - logger.info(f"Successfully mounted {table_id} with {len(df)} rows") + logger.debug(f"Successfully mounted {table_id} with {len(df)} rows") # Row count display only (removed Select All, Clear, Delete buttons) try: @@ -1044,13 +1185,19 @@ class PolicyPrepWorkflowScreen(Screen): content = self.query_one("#content_area", Vertical) content.remove_children() - # Clear message + # Add spacer to push text to bottom + spacer = Static("") + spacer.styles.height = "1fr" + content.mount(spacer) + + # Loading message at bottom (above checklist) status = Static( - "Building path exclusions and publisher lists...\n\n" + "Building path exclusions and publisher lists...\n" "This may take a moment for large datasets." ) - status.styles.margin = (2, 1) + status.styles.margin = (1, 1) status.styles.text_align = "center" + status.styles.color = "cyan" content.mount(status) # Force UI refresh to show the loading screen @@ -1311,8 +1458,8 @@ class PolicyPrepWorkflowScreen(Screen): logger.info( f"=== _calculate_paths called with path_exclusion_constant={path_exclusion_constant} ===" ) - logger.info(f"Input DataFrame: {len(df)} rows") - logger.info(f"Columns: {list(df.columns) if not df.empty else 'empty'}") + logger.debug(f"Input DataFrame: {len(df)} rows") + logger.debug(f"Columns: {list(df.columns) if not df.empty else 'empty'}") if df.empty: logger.warning("Input DataFrame is empty") @@ -1327,7 +1474,7 @@ class PolicyPrepWorkflowScreen(Screen): haslcp = self._split_filepaths_grouped(df, path_exclusion_constant, "filename") haslcp = haslcp.drop_duplicates() - logger.info(f"After split_filepaths_grouped: {len(haslcp)} rows") + logger.debug(f"After split_filepaths_grouped: {len(haslcp)} rows") # Filter forbidden paths badpathparts = get_system_list("BAD_PATH_PARTS") @@ -1337,7 +1484,7 @@ class PolicyPrepWorkflowScreen(Screen): forbidden_pattern, case=False, na=False, regex=True ) - logger.info( + logger.debug( f"Removing forbidden filepaths: {forbidden_lcfp.sum()} paths filtered" ) lcp_not_forbidden = haslcp[~forbidden_lcfp].copy() @@ -1347,7 +1494,7 @@ class PolicyPrepWorkflowScreen(Screen): ) lcp_not_forbidden = haslcp.copy() - logger.info(f"After forbidden filtering: {len(lcp_not_forbidden)} rows") + logger.debug(f"After forbidden filtering: {len(lcp_not_forbidden)} rows") # Select relevant columns if "policyname" in lcp_not_forbidden.columns: @@ -1399,11 +1546,11 @@ class PolicyPrepWorkflowScreen(Screen): lcp_not_forbidden_review = lcp_not_forbidden_review[ lcp_not_forbidden_review["unique_sha256_count"] >= min_files_for_path ] - logger.info( + logger.debug( f"After MIN_FILES_FOR_PATH filter ({min_files_for_path}): {len(lcp_not_forbidden_review)} rows (removed {before_filter - len(lcp_not_forbidden_review)})" ) - logger.info( + logger.debug( f"Final result: {len(lcp_not_forbidden_review)} rows with columns: {list(lcp_not_forbidden_review.columns)}" ) @@ -1441,7 +1588,7 @@ class PolicyPrepWorkflowScreen(Screen): def _show_path_results(self) -> None: """Show the results of path building.""" - logger.info("=== _show_path_results called ===") + logger.debug("=== _show_path_results called ===") self.workflow_stage = "second_review" content = self.query_one("#content_area", Vertical) content.remove_children() @@ -1490,15 +1637,39 @@ class PolicyPrepWorkflowScreen(Screen): logger.info("Mounted tab buttons") # Show paths table by default - logger.info("About to call _show_path_review_table('paths')") + logger.debug("About to call _show_path_review_table('paths')") self._show_path_review_table("paths") - logger.info("=== _show_path_results complete ===") + logger.debug("=== _show_path_results complete ===") def _show_path_review_table(self, table_type: str) -> None: """Show an editable DataTable for reviewing paths/publishers.""" - logger.info(f"=== _show_path_review_table called with type: {table_type} ===") + # Prevent concurrent execution + if self._creating_path_table: + logger.warning( + f"Already creating path table, ignoring duplicate call for {table_type}" + ) + return + + self._creating_path_table = True + + try: + self._show_path_review_table_impl(table_type) + finally: + self._creating_path_table = False + + def _show_path_review_table_impl(self, table_type: str) -> None: + """Internal implementation of _show_path_review_table.""" + logger.debug(f"_show_path_review_table called with type: {table_type}") content = self.query_one("#content_area", Vertical) + # Mark tab as reviewed (only paths and publishers, not remaining) + if table_type == "paths": + self.paths_tab_reviewed = True + logger.debug("Marked paths tab as reviewed") + elif table_type == "publishers": + self.publishers_tab_reviewed = True + logger.debug("Marked publishers tab as reviewed") + # Determine which dataframe to show if table_type == "paths": # Combine primary and secondary paths for review @@ -1608,6 +1779,18 @@ class PolicyPrepWorkflowScreen(Screen): except Exception as e: logger.debug(f"Error removing existing controls: {e}") + # Remove existing instruction and help text (they accumulate without removal) + # Remove ALL Static widgets - they're just text that needs to be replaced + try: + existing_statics = content.query("Static") + logger.debug( + f"Found {len(existing_statics)} existing Static widgets to remove" + ) + for static in existing_statics: + static.remove() + except Exception as e: + logger.debug(f"Error removing Static widgets: {e}") + # Force refresh to ensure removals complete try: content.refresh() @@ -1620,13 +1803,13 @@ class PolicyPrepWorkflowScreen(Screen): content.mount(empty_msg) return - # Instructions + # Instructions (no ID needed) instruction = Static(title) instruction.styles.margin = (1, 1) instruction.styles.text_style = "bold" content.mount(instruction) - # Help text (different for remaining hashes) + # Help text (different for remaining hashes, no ID needed) if table_type != "remaining": help_text = Static( "Click to toggle, 'r' for range select (click start, press 'r', click end)\n" @@ -1653,11 +1836,11 @@ class PolicyPrepWorkflowScreen(Screen): available_cols = [col for col in columns if col in df.columns] if available_cols: # Add checkbox column first - review_table.add_columns("☐", *available_cols) + review_table.add_columns("β—‹", *available_cols) # Add rows with row keys for tracking for idx, row in df.iterrows(): - checkbox = "☐" # All start unchecked + checkbox = "β—‹" # All start unchecked row_data = [] for col in available_cols: value = row.get(col, "") @@ -1669,7 +1852,7 @@ class PolicyPrepWorkflowScreen(Screen): row_data.append(str(value)) review_table.add_row(checkbox, *row_data, key=str(idx)) - logger.info(f"About to mount {table_id}") + logger.debug(f"About to mount {table_id}") # Final safety check before mounting table try: @@ -1682,7 +1865,7 @@ class PolicyPrepWorkflowScreen(Screen): pass content.mount(review_table) - logger.info(f"Successfully mounted {table_id}") + logger.debug(f"Successfully mounted {table_id}") # Row count display only (removed Select All, Clear, Delete buttons) if table_type != "remaining": @@ -1999,53 +2182,166 @@ class PolicyPrepWorkflowScreen(Screen): """Perform the actual application of changes.""" try: results = [] + errors = [] - # Apply path exclusions to policy - if self.destination_policy and self.primary_paths_df is not None: - # This would call the actual API methods - results.append("Applied path exclusions to policy") + # Apply path exclusions to policy (primary + secondary) + if self.destination_policy: + path_rules = [] + + # Process primary paths + if ( + self.primary_paths_df is not None + and not self.primary_paths_df.empty + ): + logger.info( + f"Processing {len(self.primary_paths_df)} primary paths" + ) + for _, row in self.primary_paths_df.groupby( + ["longestcfp", "file_extension"] + ): + path = row.iloc[0]["longestcfp"] + ext = row.iloc[0]["file_extension"] + # Format: C:\Path\**.ext + path_rule = f"{path}\\**{ext}" + path_rules.append(path_rule) + + # Process secondary paths + if ( + self.secondary_paths_df is not None + and not self.secondary_paths_df.empty + ): + logger.info( + f"Processing {len(self.secondary_paths_df)} secondary paths" + ) + for _, row in self.secondary_paths_df.groupby( + ["longestcfp", "file_extension"] + ): + path = row.iloc[0]["longestcfp"] + ext = row.iloc[0]["file_extension"] + path_rule = f"{path}\\**{ext}" + path_rules.append(path_rule) + + # Apply path rules to policy + if path_rules: + try: + logger.info( + f"Applying {len(path_rules)} path exclusions to policy {self.destination_policy.name}" + ) + response = self.api.policy_add_path_exclusions( + str(self.destination_policy.groupid), path_rules + ) + results.append( + f"βœ“ Added {len(path_rules)} path exclusions to policy" + ) + logger.info(f"Path exclusions applied successfully: {response}") + except Exception as e: + error_msg = f"βœ— Failed to add path exclusions: {str(e)}" + errors.append(error_msg) + logger.error(error_msg, exc_info=True) # Apply publishers to policy - if self.destination_policy and self.publishers_df is not None: - # This would call the actual API methods - results.append("Applied approved publishers to policy") + if ( + self.destination_policy + and self.publishers_df is not None + and not self.publishers_df.empty + ): + try: + publishers = self.publishers_df["publisher"].unique().tolist() + logger.info( + f"Applying {len(publishers)} publishers to policy {self.destination_policy.name}" + ) + response = self.api.policy_add_publishers( + str(self.destination_policy.groupid), publishers + ) + results.append( + f"βœ“ Added {len(publishers)} trusted publishers to policy" + ) + logger.info(f"Publishers applied successfully: {response}") + except Exception as e: + error_msg = f"βœ— Failed to add publishers: {str(e)}" + errors.append(error_msg) + logger.error(error_msg, exc_info=True) # Apply hashes to allowlist - if self.destination_allowlist and self.approved_df is not None: - # This would call the actual API methods - results.append("Applied approved hashes to allowlist") + if ( + self.destination_allowlist + and self.approved_df is not None + and not self.approved_df.empty + ): + try: + # Get unique hashes + hashes = self.approved_df["sha256"].unique().tolist() + logger.info( + f"Applying {len(hashes)} hashes to allowlist {self.destination_allowlist.name}" + ) + response = self.api.hash_add_to_allowlist( + str(self.destination_allowlist.applicationid), hashes + ) + results.append( + f"βœ“ Added {len(hashes):,} approved hashes to allowlist" + ) + logger.info(f"Hashes applied successfully: {response}") + except Exception as e: + error_msg = f"βœ— Failed to add hashes: {str(e)}" + errors.append(error_msg) + logger.error(error_msg, exc_info=True) - self._show_completion(results) + # Show completion with both results and errors + all_results = results + errors + self._show_completion(all_results, has_errors=len(errors) > 0) except Exception as e: - logger.error(f"Failed to apply changes: {e}", exc_info=True) - self.app.notify(f"Failed to apply changes: {str(e)}", severity="error") + logger.error(f"Critical failure in _perform_apply: {e}", exc_info=True) + self.app.notify(f"Critical failure: {str(e)}", severity="error") self._show_test_screen() - def _show_completion(self, results: List[str]) -> None: + def _show_completion(self, results: List[str], has_errors: bool = False) -> None: """Show completion screen.""" self.workflow_stage = "complete" content = self.query_one("#content_area", Vertical) content.remove_children() - summary = Static( - "Policy Preparation Complete!\n\n" - "The following changes have been applied:" - ) + # Title depends on whether there were errors + if has_errors: + title_text = "Policy Preparation Completed with Errors\n\n" "Results:" + title_color = "yellow" + else: + title_text = ( + "Policy Preparation Complete!\n\n" + "The following changes have been applied:" + ) + title_color = "green" + + summary = Static(title_text) summary.styles.margin = (1, 1) + summary.styles.text_style = "bold" + summary.styles.color = title_color content.mount(summary) for result in results: result_widget = Static(f" {result}") result_widget.styles.margin = (0, 2) + # Color based on success/failure + if result.startswith("βœ“"): + result_widget.styles.color = "green" + elif result.startswith("βœ—"): + result_widget.styles.color = "red" content.mount(result_widget) # Final message - final = Static( - f"\nPolicy '{self.destination_policy.name}' is now ready for enforcement!" - ) - final.styles.margin = (2, 1) - final.styles.color = "green" + if has_errors: + final = Static( + f"\n⚠️ Policy '{self.destination_policy.name}' was partially updated.\n" + "Please review errors above and retry failed operations manually." + ) + final.styles.margin = (2, 1) + final.styles.color = "yellow" + else: + final = Static( + f"\nβœ… Policy '{self.destination_policy.name}' is now ready for enforcement!" + ) + final.styles.margin = (2, 1) + final.styles.color = "green" content.mount(final) # Done button @@ -2078,7 +2374,7 @@ class PolicyPrepWorkflowScreen(Screen): ) # Determine if this row should be checked is_selected = row_key_str in selected_keys - checkbox = "β˜‘οΈ" if is_selected else "☐" + checkbox = "βœ“" if is_selected else "β—‹" # Update the checkbox cell (first column, index 0) try: @@ -2315,8 +2611,15 @@ class PolicyPrepWorkflowScreen(Screen): """Handle button presses.""" button_id = event.button.id + # Introduction screen buttons + if button_id == "start_workflow": + self._show_source_policy_selection() + + elif button_id == "cancel_workflow": + self.app.pop_screen() + # Source policy selection buttons - if button_id == "select_none_source": + elif button_id == "select_none_source": self.selected_source_policy_ids.clear() # Refresh checkbox display self._refresh_table_checkboxes( @@ -2434,6 +2737,15 @@ class PolicyPrepWorkflowScreen(Screen): ) elif button_id == "continue_from_review": + # Check if both tabs have been reviewed + if not self.approved_tab_reviewed or not self.needs_review_tab_reviewed: + self.app.notify( + "Please review both 'Approved' and 'Needs Review' tabs before continuing.", + severity="warning", + timeout=5, + ) + return + # Validate that review is complete if (self.approved_df is None or self.approved_df.empty) and ( self.needs_review_df is None or self.needs_review_df.empty @@ -2449,6 +2761,15 @@ class PolicyPrepWorkflowScreen(Screen): self._show_path_building_screen() elif button_id == "build_preflight": + # Check if both required tabs have been reviewed + if not self.paths_tab_reviewed or not self.publishers_tab_reviewed: + self.app.notify( + "Please review both 'Paths' and 'Publishers' tabs before continuing.", + severity="warning", + timeout=5, + ) + return + # Validate that path review is complete if (self.primary_paths_df is None or self.primary_paths_df.empty) and ( self.publishers_df is None or self.publishers_df.empty diff --git a/TUI/TUI.py b/TUI/TUI.py deleted file mode 100644 index 1a6f603..0000000 --- a/TUI/TUI.py +++ /dev/null @@ -1,444 +0,0 @@ -# Copyright (C) 2025 James Brotosky, Brandon Wickline -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import logging -import os -from typing import Optional - -import dotenv -from textual.app import App, ComposeResult -from textual.containers import Vertical -from textual.message import Message -from textual.reactive import reactive -from textual.screen import Screen -from textual.widgets import ( - Button, - DirectoryTree, - Footer, - Header, - Static, - Tab, - Tabs, -) - -from models.agent import Agent -from models.policy import Policy -from services.API import AirlockAPIWrapper -from TUI.Screens.moveagentworkflowscreen import MoveAgentWorkflowScreen -from TUI.Screens.otpactivityscreen import OTPActivitiesScreen -from TUI.Screens.otprevokescreen import OTPRevokeScreen -from TUI.Screens.otpworkflowscreen import OTPWorkflowScreen -from TUI.Screens.policyprepworkflowscreen import PolicyPrepWorkflowScreen -from TUI.Screens.quietagentworkflowscreen import QuietAgentWorkflowScreen -from TUI.Themes.theme_amber_terminal import get_amber_terminal_theme -from TUI.Themes.theme_retro_terminal import get_retro_terminal_theme -from TUI.Themes.themeselector import ThemeSelector -from TUI.Widgets.agentmoveoperations import AgentMoveOperations -from TUI.Widgets.multiagentselector import MultiAgentSelector -from TUI.Widgets.policytreewidget import PolicyTreeWidget -from TUI.Widgets.resultsdisplay import ResultsDisplay -from utils.configmanager import get_user_value, load_env, save_user_config -from utils.setup import get_base_directory -from utils.utils import open_directory - -dotenv.load_dotenv() - -# --------------------------------------------------------------------------- -# GLOBAL STASH -# --------------------------------------------------------------------------- - -_APP_RESTART_REASON = None - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# helper to persist TEXTUAL_THEME to *user* config and mirror to .env -# --------------------------------------------------------------------------- -def _persist_user_theme(theme_name: str) -> None: - """ - Store the chosen Textual theme in the user's config using the config manager. - No need to touch .env - config manager handles everything. - """ - base_dir = get_base_directory() - config_dir = base_dir / "config" - - try: - save_user_config(config_dir, {"TEXTUAL_THEME": theme_name}) - logger.debug("Updated user config with TEXTUAL_THEME=%s", theme_name) - except Exception as exc: - logger.error("Failed to save TEXTUAL_THEME: %s", exc) - - -# --------------------------------------------------------------------------- -# 1) SCREEN -# --------------------------------------------------------------------------- -class MainMenuScreen(Screen): - api: AirlockAPIWrapper - current_tab = reactive("") - - BUTTON_DEFS = { - "agent_actions": [ - ( - "πŸ–₯️ - Find agent, Move agent, or Generate One Time Pass", - "move_agent_workflow_button", - ), - ("🎫 - Review and approve OTP Activities", "otp_activities_button"), - ("πŸ›‘ - Revoke Active OTP Session", "otp_revoke_button"), - ], - "policy": [ - ("βš–οΈ - Prepare Policy For Enforcement", "policy_prep_button"), - ("πŸ”• - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), - ], - } - - def __init__(self) -> None: - super().__init__() - self.extras = get_user_value("EXTRAS", str, "NOTTODAY") - wd = load_env("WORKING_DIR") or os.getcwd() - if not os.path.isdir(wd): - wd = os.getcwd() - self.working_dir = wd - - def _make_buttons_for(self, tab_id: str) -> Vertical: - defs = self.BUTTON_DEFS.get(tab_id, []) - buttons = [] - for label, btn_id in defs: - btn = Button(label, id=btn_id) - btn.styles.width = "100%" - buttons.append(btn) - return Vertical(*buttons) - - def compose(self) -> ComposeResult: - yield Header(show_clock=True, icon="βš™") - - tabs = [ - Tab("Tree View", id="p_tree"), - Tab("Agents", id="agent_actions"), - Tab("Directory", id="dir"), - Tab("Settings", id="settings"), - ] - - if self.extras == "POLICYPREP": - tabs.insert(2, Tab("Policy Prep", id="policy")) - - yield Tabs(*tabs, id="tabs") - yield Vertical(id="content") - yield Footer() - - def on_mount(self) -> None: - self.switch_tab("agent_actions") - - # focus helpers - def _get_content_buttons(self) -> list[Button]: - content = self.query_one("#content", Vertical) - return list(content.query(Button)) - - def _focus_first_button(self) -> None: - buttons = self._get_content_buttons() - if buttons: - buttons[0].focus() - - def _focus_tabs(self) -> None: - tabs = self.query_one("#tabs", Tabs) - tabs.focus() - - def _focus_nearby_button(self, direction: int) -> None: - buttons = self._get_content_buttons() - if not buttons: - return - - try: - current = next(i for i, b in enumerate(buttons) if b.has_focus) - except StopIteration: - if direction > 0: - buttons[0].focus() - else: - buttons[-1].focus() - return - - if direction < 0 and current == 0: - self._focus_tabs() - return - - new_index = current + direction - if 0 <= new_index < len(buttons): - buttons[new_index].focus() - - def switch_tab(self, tab_id: str) -> None: - self.current_tab = tab_id - content = self.query_one("#content", Vertical) - content.remove_children() - - if tab_id in self.BUTTON_DEFS: - content.mount(self._make_buttons_for(tab_id)) - self.call_later(self._focus_first_button) - elif tab_id == "dir": - content.mount(DirectoryTree(self.working_dir, id="dir_tree")) - elif tab_id == "p_tree": - content.mount(PolicyTreeWidget(self.app.policies, self.app.devices)) - elif tab_id == "settings": - content.mount(ThemeSelector()) - else: - content.mount(Static(f"Unknown tab: {tab_id}")) - - def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: - self.switch_tab(event.tab.id) - - def on_multi_agent_selector_agents_selected( - self, message: MultiAgentSelector.AgentsSelected - ) -> None: - """Handle selected agents from AgentSelector.""" - global _APP_RESTART_REASON - selected_agents = message.selected_agents - logger.info("Selected agents: %s", selected_agents) - # TODO: Implement actual handling of selected agents - _APP_RESTART_REASON = ("multi_agent_action", selected_agents) - self.app.exit() - - def on_theme_selector_theme_selected( - self, message: ThemeSelector.ThemeSelected - ) -> None: - """Handle theme selection from ThemeSelector.""" - global _APP_RESTART_REASON - _persist_user_theme(message.theme_name) - _APP_RESTART_REASON = ("restart",) - self.app.exit() - - def on_agent_move_operations_operation_complete( - self, message: AgentMoveOperations.OperationComplete - ) -> None: - """Handle completion of agent move operation - show results.""" - logger.info( - "Agent move operation completed: %s, %d successful, %d unsuccessful", - message.operation, - len(message.successful), - len(message.unsuccessful), - ) - - # Format results for display - successful_text = "\n".join( - [f"{agent.hostname}" for agent, _ in message.successful] - ) - unsuccessful_text = "\n".join( - [f"{agent.hostname}: {error}" for agent, error in message.unsuccessful] - ) - - # Remove the operations widget - try: - ops_widget = self.query_one(AgentMoveOperations) - ops_widget.remove() - except Exception: - pass - - # Show results - self.query_one("#content", Vertical).mount( - ResultsDisplay(message.operation, successful_text, unsuccessful_text) - ) - - def on_results_display_go_back(self, message: ResultsDisplay.GoBack) -> None: - """Handle back button from results display.""" - try: - results_widget = self.query_one(ResultsDisplay) - results_widget.remove() - except Exception: - pass - # Return to main menu - self.app.pop_screen() - - def on_directory_tree_file_selected( - self, event: DirectoryTree.FileSelected - ) -> None: - path = event.path - logger.debug("Directory file selected: %s", path) - try: - open_directory(str(path)) - except Exception as exc: - logger.error("Failed to open %s: %s", path, exc) - self.app.bell() - - def on_button_pressed(self, event: Button.Pressed) -> None: - button_id = event.button.id - logger.debug("Button pressed: %s", button_id) - - match button_id: - case "move_agent_workflow_button": - self.app.push_screen(MoveAgentWorkflowScreen(self.app.devices)) - event.stop() - - case "otp_generate_button": - self.app.push_screen(OTPWorkflowScreen(self.app.devices)) - event.stop() - - case "find_quiet_button": - self.app.push_screen( - QuietAgentWorkflowScreen(self.app.api, self.app.policies) - ) - event.stop() - return - - case "otp_activities_button": - self.app.push_screen(OTPActivitiesScreen()) - event.stop() - return - - case "otp_revoke_button": - self.app.push_screen(OTPRevokeScreen()) - event.stop() - return - - case "policy_prep_button": - # Use the new TUI workflow screen instead of legacy - self.app.push_screen( - PolicyPrepWorkflowScreen(self.app.api, self.app.policies) - ) - event.stop() - return - - case _: - self.app.bell() - logger.warning("Unknown button pressed: %s", button_id) - return - - -# --------------------------------------------------------------------------- -# 2) APP -# --------------------------------------------------------------------------- -class Loxide(App[Message]): - api: AirlockAPIWrapper - working_dir: str - policies: Optional[list[Policy]] - devices: Optional[list[Agent]] - - CSS = """ - #logo { - width: 100%; - content-align: center middle; - text-align: center; - } - """ - BINDINGS = [ - ("q", "quit", "Quit"), - ("f", "open_fe", "Launch Explorer"), - ("r", "refresh", "Refresh"), - ] - - def __init__(self, api: AirlockAPIWrapper): - self._textual_theme = get_user_value("TEXTUAL_THEME", str, "textual-dark") - super().__init__() - self.api = api - wd = load_env("WORKING_DIR") or os.getcwd() - if not os.path.isdir(wd): - wd = os.getcwd() - self.working_dir = wd - # Initial data load - self.refresh_data() - - def refresh_data(self) -> None: - """Public method to refresh policies and devices from the API.""" - try: - self.policies = [ - Policy(**row.to_dict()) - for _, row in self.api.policy_find_all().iterrows() - ] - self.devices = [ - Agent(**row.to_dict()) - for _, row in self.api.agent_find_all().iterrows() - ] - if self.policies and self.devices: - for agent in self.devices: - agent.enrich_with_policies(self.policies) - logger.debug( - f"Enriched {len(self.devices)} agents with policy information" - ) - except Exception as exc: - logger.error("Failed to load policies/devices: %s", exc) - self.policies = None - self.devices = None - - def on_mount(self, api: AirlockAPIWrapper) -> None: - self.register_theme(get_retro_terminal_theme()) - self.register_theme(get_amber_terminal_theme()) - self.theme = self._textual_theme - self.push_screen(MainMenuScreen()) - - def action_refresh(self) -> None: - self.refresh_data() - - def action_quit(self) -> None: - global _APP_RESTART_REASON - _APP_RESTART_REASON = None - self.exit() - - def action_open_fe(self) -> None: - """Open the working directory in the OS file manager (footer binding).""" - path_to_open = self.working_dir or os.getcwd() - try: - open_directory(path_to_open) - except Exception as exc: - logger.error("Failed to open directory %s: %s", path_to_open, exc) - self.bell() # optional feedback - - -# --------------------------------------------------------------------------- -# 3) PUBLIC ENTRYPOINT -# --------------------------------------------------------------------------- -def run_Loxide(api: AirlockAPIWrapper) -> None: - global _APP_RESTART_REASON - base_dir = get_base_directory() - env_path = base_dir / ".env" - dotenv.load_dotenv(dotenv_path=env_path, override=True) - - max_attempts = 5 - attempts = 0 - - while attempts < max_attempts: - attempts += 1 - logger.debug("Starting app loop iteration (attempt %d)", attempts) - _APP_RESTART_REASON = None - app = Loxide(api) - - try: - app.run() - except SystemExit as exc: - if exc.code != 0: - logger.debug("Caught SystemExit from Textual: %s", exc) - raise - - reason = _APP_RESTART_REASON - logger.debug("After app.run(), _APP_RESTART_REASON = %r", reason) - - if not reason: - logger.debug("No restart reason, exiting loop") - break - - if reason[0] == "restart": - logger.debug("Restarting app loop") - continue - - if reason[0] == "multi_agent_action": - logger.info("Multi-agent action with selected agents: %s", reason[1]) - continue - - logger.error("Unknown restart reason: %r", reason) - break - - -# --------------------------------------------------------------------------- -# 4) DEV -# --------------------------------------------------------------------------- -if __name__ == "__main__": - api = AirlockAPIWrapper() - run_Loxide(api) diff --git a/requirements.txt b/requirements.txt index 6492e2b..18ab1da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,26 @@ +# Core TUI dependencies +textual==6.5.0 + +# API and data handling +Requests==2.32.5 +pandas==2.3.3 +numpy==2.3.4 + +# Database +pymongo==4.15.3 + +# Security and encryption cryptography==46.0.3 keyring==25.6.0 -numpy==2.3.4 -pandas==2.3.3 -pymongo==4.15.3 + +# Environment management python-dotenv==1.2.1 -Requests==2.32.5 -textual==6.5.0 + +# Utilities tqdm==4.67.1 urllib3==2.5.0 pyperclip==1.11.0 +# Custom/Private packages --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ airlock_libs==6.0.0 \ No newline at end of file -- 2.34.1 From f080b0034f0e33d32be4f6b6154bd5b2317e2f72 Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Mon, 15 Dec 2025 17:04:17 -0500 Subject: [PATCH 21/25] Changes: Features Moved the init_tracer() function to an implementation in TelemetryConfig for cleaner main file closes #43 Bug Fixes Telemetry is now opt-in again. Due to changes in opentelemetry, this required a major overhaul of the telemetryconfig function. closes #44 --- airlock_libs/Cargo.lock | 336 +++++++++++++++++++++++--- airlock_libs/Cargo.toml | 6 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/modules/datatypes.rs | 27 ++- airlock_libs/src/modules/updater.rs | 0 airlock_libs/src/prelude.rs | 5 + airlock_libs/src/services.rs | 35 +-- requirements.txt | 2 +- 8 files changed, 346 insertions(+), 67 deletions(-) create mode 100644 airlock_libs/src/modules/updater.rs diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 2f841d6..4b06811 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -24,6 +24,34 @@ dependencies = [ "memchr", ] +[[package]] +name = "airlock_libs" +version = "6.1.0" +dependencies = [ + "chrono", + "crossbeam", + "flexi_logger", + "indicatif", + "log", + "mongodb", + "opentelemetry 0.27.1", + "opentelemetry-appender-log", + "opentelemetry-otlp", + "opentelemetry-proto", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk 0.27.1", + "pyo3", + "reqwest", + "serde", + "serde-pyobject", + "serde_json", + "tokio", + "tonic", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -39,6 +67,150 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -61,6 +233,12 @@ dependencies = [ "syn", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -164,6 +342,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bson" version = "2.15.0" @@ -234,6 +425,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.16.1" @@ -555,6 +755,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -649,6 +876,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -732,6 +972,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.4.12" @@ -769,6 +1021,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1198,6 +1456,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1236,6 +1503,9 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] [[package]] name = "lru-slab" @@ -1625,6 +1895,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "231e9d6ceef9b0b2546ddf52335785ce41252bc7474ee8ba05bfad277be13ab8" dependencies = [ + "async-std", "async-trait", "futures-channel", "futures-executor", @@ -1655,6 +1926,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1725,12 +2002,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -2413,34 +2715,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signoz_test" -version = "6.0.0" -dependencies = [ - "chrono", - "crossbeam", - "flexi_logger", - "indicatif", - "log", - "mongodb", - "opentelemetry 0.27.1", - "opentelemetry-appender-log", - "opentelemetry-otlp", - "opentelemetry-proto", - "opentelemetry-semantic-conventions", - "opentelemetry_sdk 0.27.1", - "pyo3", - "reqwest", - "serde", - "serde-pyobject", - "serde_json", - "tokio", - "tonic", - "tracing", - "tracing-opentelemetry", - "tracing-subscriber", -] - [[package]] name = "slab" version = "0.4.11" @@ -3083,6 +3357,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 5908dc9..9bbf056 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "signoz_test" -version = "6.0.0" +name = "airlock_libs" +version = "6.1.0" edition = "2024" [dependencies] @@ -25,7 +25,7 @@ crossbeam = "0.8.4" log = "0.4.29" flexi_logger = "0.31.7" opentelemetry-appender-log = "0.27.0" -opentelemetry_sdk = { version = "0.27.0", features = ["rt-tokio", "trace"] } +opentelemetry_sdk = { version = "0.27.0", features = ["rt-tokio", "testing", "trace"] } [package.metadata.maturin] generate-abi-stubs = true diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index 763803b..dcf034e 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "6.0.0" +version = "6.1.0" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/modules/datatypes.rs b/airlock_libs/src/modules/datatypes.rs index 9b4b351..72f50d8 100644 --- a/airlock_libs/src/modules/datatypes.rs +++ b/airlock_libs/src/modules/datatypes.rs @@ -8,7 +8,32 @@ pub struct TelemetryConfig { } impl TelemetryConfig { - pub fn load() -> Self { + pub fn init_tracer() -> opentelemetry_sdk::trace::TracerProvider { + let cfg: TelemetryConfig = TelemetryConfig::load(); + if !cfg.TELEMETRY { + return TracerProvider::builder().build(); + } + let endpoint = cfg.TELEM_URL.unwrap_or_default(); + let channel = Channel::from_shared(endpoint.clone()) + .unwrap() + .tls_config(ClientTlsConfig::new().with_native_roots()) + .unwrap() + .connect_lazy(); + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_tonic() + .with_endpoint(endpoint.clone()) + .with_channel(channel) + .build() + .expect("Failed to build exporter"); + opentelemetry_sdk::trace::TracerProvider::builder() + .with_simple_exporter(exporter) + .with_resource(Resource::new(vec![KeyValue::new( + "service.name", + "LoxideLibs", + )])) + .build() + } + fn load() -> Self { let cfg_path = get_base_directory().join("config\\user_config.json"); if !cfg_path.exists() { return Self { diff --git a/airlock_libs/src/modules/updater.rs b/airlock_libs/src/modules/updater.rs new file mode 100644 index 0000000..e69de29 diff --git a/airlock_libs/src/prelude.rs b/airlock_libs/src/prelude.rs index e2ffa2e..da633a9 100644 --- a/airlock_libs/src/prelude.rs +++ b/airlock_libs/src/prelude.rs @@ -1,4 +1,5 @@ pub use chrono::{Duration, Local, NaiveDate}; +pub use crossbeam::channel::unbounded; pub use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; pub use mongodb::bson::oid::ObjectId; pub use opentelemetry::global::GlobalTracerProvider; @@ -7,6 +8,7 @@ pub use opentelemetry::trace::{Status, TraceContextExt, Tracer}; pub use opentelemetry::*; pub use opentelemetry_otlp::ExportConfig; pub use opentelemetry_otlp::WithExportConfig; +pub use opentelemetry_otlp::WithTonicConfig; pub use opentelemetry_sdk::Resource; pub use opentelemetry_sdk::trace::{Config, TracerProvider}; pub use pyo3::{prelude::*, types::PyString}; @@ -16,6 +18,8 @@ pub use reqwest::{ }; pub use serde::{Deserialize, Serialize}; pub use serde_json::Value; +pub use std::sync::{Arc, Mutex}; +pub use std::thread; pub use std::{ collections::HashMap, env, @@ -25,3 +29,4 @@ pub use std::{ path::PathBuf, str::FromStr, }; +pub use tonic::transport::{Channel, ClientTlsConfig}; diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index 6a9f3a9..c0b6cc2 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -1,10 +1,6 @@ use crate::modules::datatypes::*; use crate::prelude::*; -use crossbeam::channel::unbounded; -use opentelemetry_otlp::WithTonicConfig; -use std::sync::{Arc, Mutex}; -use std::thread; -use tonic::transport::{Channel, ClientTlsConfig}; + #[pyfunction] pub fn pull_policy_exec_histories( py: Python<'_>, @@ -25,7 +21,7 @@ pub fn pull_policy_exec_histories( std::process::abort(); } }; - let tracer_provider = rt.block_on(async { init_tracer() }); + let tracer_provider = rt.block_on(async { TelemetryConfig::init_tracer() }); global::set_tracer_provider(tracer_provider.clone()); let tracer: global::BoxedTracer = global::tracer("tracer"); let _cx: Context = Context::new(); @@ -184,9 +180,6 @@ pub fn pull_policy_exec_histories( .unwrap() .enable_steady_tick(std::time::Duration::from_millis(100)); let span: opentelemetry::trace::SpanRef<'_> = cx.span(); - //span.set_attribute(Key::new("Days").string(days.to_string())); - //span.set_attribute(Key::new("Days")); - //span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); span.set_attribute(KeyValue::new("Days", days)); span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); loop { @@ -320,27 +313,3 @@ pub fn get_base_directory() -> PathBuf { } } } - -fn init_tracer() -> opentelemetry_sdk::trace::TracerProvider { - let cfg: TelemetryConfig = TelemetryConfig::load(); - let endpoint = cfg.TELEM_URL.unwrap_or_default().clone(); - let channel_endpoint = endpoint.clone(); - let channel = Channel::from_shared(channel_endpoint.clone()) - .unwrap() - .tls_config(ClientTlsConfig::new().with_native_roots()) - .unwrap() - .connect_lazy(); - let exporter = opentelemetry_otlp::SpanExporter::builder() - .with_tonic() - .with_endpoint(endpoint.clone()) - .with_channel(channel) - .build() - .expect("Failed to build exporter"); - opentelemetry_sdk::trace::TracerProvider::builder() - .with_simple_exporter(exporter) - .with_resource(Resource::new(vec![KeyValue::new( - "service.name", - "LoxideLibs", - )])) - .build() -} diff --git a/requirements.txt b/requirements.txt index 6492e2b..68e23a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==6.0.0 \ No newline at end of file +airlock_libs==6.1.0 \ No newline at end of file -- 2.34.1 From fc17c869fc178d689c3190b3412a806f29310e3c Mon Sep 17 00:00:00 2001 From: Zarithas Date: Tue, 16 Dec 2025 16:16:49 -0500 Subject: [PATCH 22/25] feat: Multiple UI improvements and new server log functionality - Add Server Log tab with DataTable display of server activity logs - Fix keyboard navigation bug in agents tab - Add execution history viewer for selected agents - Improve policy tree widget functionality by adding single device operations - Integrate logging notifications into TUI - Add TextualNotificationHandler to setup.py - Display ERROR/WARNING/CRITICAL logs as toast notifications - Remove terminal output to prevent interference with TUI - Logs still written to Loxide.log file closes #45 --- Loxide.py | 152 +++++- TUI/Screens/executionhistoryscreen.py | 639 ++++++++++++++++++++++++++ TUI/Widgets/agentmoveoperations.py | 92 +++- TUI/Widgets/policytreewidget.py | 101 +++- TUI/Widgets/serverlogwidget.py | 159 +++++++ services/API.py | 8 + utils/setup.py | 97 +++- 7 files changed, 1183 insertions(+), 65 deletions(-) create mode 100644 TUI/Screens/executionhistoryscreen.py create mode 100644 TUI/Widgets/serverlogwidget.py diff --git a/Loxide.py b/Loxide.py index 912ba22..46f97af 100644 --- a/Loxide.py +++ b/Loxide.py @@ -46,6 +46,7 @@ from models.agent import Agent from models.policy import Policy from services.API import AirlockAPIWrapper from services.security import getAPI +from TUI.Screens.executionhistoryscreen import ExecutionHistoryScreen from TUI.Screens.moveagentworkflowscreen import MoveAgentWorkflowScreen from TUI.Screens.otpactivityscreen import OTPActivitiesScreen from TUI.Screens.otprevokescreen import OTPRevokeScreen @@ -59,6 +60,7 @@ from TUI.Widgets.agentmoveoperations import AgentMoveOperations from TUI.Widgets.multiagentselector import MultiAgentSelector from TUI.Widgets.policytreewidget import PolicyTreeWidget from TUI.Widgets.resultsdisplay import ResultsDisplay +from TUI.Widgets.serverlogwidget import ServerLogWidget from utils.configmanager import ( get_system_value, get_user_value, @@ -107,16 +109,29 @@ class MainMenuScreen(Screen): BUTTON_DEFS = { "agent_actions": [ - ( - "πŸ–₯️ - Find agent, Move agent, or Generate One Time Pass", - "move_agent_workflow_button", - ), - ("🎫 - Review and approve OTP Activities", "otp_activities_button"), - ("πŸ›‘ - Revoke Active OTP Session", "otp_revoke_button"), + { + "label": "πŸ–₯️ - Multi-Agent Operations", + "id": "move_agent_workflow_button", + "description": "Select agents to: Move policies, Generate OTPs, Toggle audit/enforcement, View history, Export data", + }, + { + "label": "🎫 - Review and approve OTP Activities", + "id": "otp_activities_button", + }, + { + "label": "πŸ›‘ - Revoke Active OTP Session", + "id": "otp_revoke_button", + }, ], "policy": [ - ("βš–οΈ - Prepare Policy For Enforcement", "policy_prep_button"), - ("πŸ”• - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), + { + "label": "βš–οΈ - Prepare Policy For Enforcement", + "id": "policy_prep_button", + }, + { + "label": "πŸ”• - Find and Move Quiet Hosts to Enforcement", + "id": "find_quiet_button", + }, ], } @@ -130,19 +145,40 @@ class MainMenuScreen(Screen): def _make_buttons_for(self, tab_id: str) -> Vertical: defs = self.BUTTON_DEFS.get(tab_id, []) - buttons = [] - for label, btn_id in defs: + widgets = [] + for item in defs: + # Support both old tuple format and new dict format + if isinstance(item, dict): + label = item["label"] + btn_id = item["id"] + description = item.get("description") + else: + # Old tuple format: (label, id) + label, btn_id = item + description = None + btn = Button(label, id=btn_id) btn.styles.width = "100%" - buttons.append(btn) - return Vertical(*buttons) + widgets.append(btn) + + # Add description text if provided + if description: + desc_text = Static(description, classes="button_description") + desc_text.styles.width = "100%" + desc_text.styles.color = "ansi_bright_black" + desc_text.styles.text_align = "center" + desc_text.styles.margin = (0, 0, 1, 0) + widgets.append(desc_text) + + return Vertical(*widgets) def compose(self) -> ComposeResult: yield Header(show_clock=True, icon="βš™") tabs = [ - Tab("Tree View", id="p_tree"), Tab("Agents", id="agent_actions"), + Tab("Tree View", id="p_tree"), + Tab("Server Log", id="server_log"), Tab("Directory", id="dir"), Tab("Settings", id="settings"), ] @@ -157,6 +193,18 @@ class MainMenuScreen(Screen): def on_mount(self) -> None: self.switch_tab("agent_actions") + def on_key(self, event) -> None: + """Handle up/down arrow keys for button navigation.""" + if event.key == "down": + self._focus_nearby_button(1) + event.prevent_default() + event.stop() + elif event.key == "up": + self._focus_nearby_button(-1) + event.prevent_default() + event.stop() + # left/right are handled by Textual's default tab navigation + # focus helpers def _get_content_buttons(self) -> list[Button]: content = self.query_one("#content", Vertical) @@ -200,7 +248,8 @@ class MainMenuScreen(Screen): if tab_id in self.BUTTON_DEFS: content.mount(self._make_buttons_for(tab_id)) - self.call_later(self._focus_first_button) + elif tab_id == "server_log": + content.mount(ServerLogWidget(self.app.api)) elif tab_id == "dir": content.mount(DirectoryTree(self.working_dir, id="dir_tree")) elif tab_id == "p_tree": @@ -274,6 +323,66 @@ class MainMenuScreen(Screen): # Return to main menu self.app.pop_screen() + def on_policy_tree_widget_view_execution_history( + self, message: PolicyTreeWidget.ViewExecutionHistory + ) -> None: + """Handle request to view execution history for a device from tree view.""" + logger.info("Viewing execution history for device: %s", message.device.hostname) + self.app.push_screen(ExecutionHistoryScreen([message.device])) + message.stop() + + def on_policy_tree_widget_generate_otp( + self, message: PolicyTreeWidget.GenerateOTP + ) -> None: + """Handle request to generate OTP for a device from tree view.""" + logger.info("Generating OTP for device: %s", message.device.hostname) + self.app.push_screen(OTPWorkflowScreen([message.device])) + message.stop() + + def on_policy_tree_widget_toggle_enforcement( + self, message: PolicyTreeWidget.ToggleEnforcement + ) -> None: + """Handle request to toggle enforcement for a device from tree view.""" + logger.info("Toggling enforcement for device: %s", message.device.hostname) + + try: + from services.agenthandler import moveAgentToRelatedPolicy + from utils.configmanager import get_system_json + + policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") + + # Determine current mode and toggle + if message.device.groupid in policy_relationship_map: + # Currently in enforcement, move to audit + result = moveAgentToRelatedPolicy(self.app.api, message.device, "audit") + mode = "audit" + else: + # Currently in audit, move to enforcement + result = moveAgentToRelatedPolicy( + self.app.api, message.device, "enforcement" + ) + mode = "enforcement" + + logger.info(f"Successfully toggled {message.device.hostname} to {mode}") + + # Refresh data at the app level + self.app.refresh_data() + + # Refresh the tree widget with new data + try: + tree_widget = self.query_one(PolicyTreeWidget) + tree_widget.refresh_data(self.app.policies, self.app.devices) + except: + pass + + except Exception as e: + logger.error( + f"Failed to toggle enforcement for {message.device.hostname}: {e}" + ) + self.app.bell() + + message.stop() + def on_directory_tree_file_selected( self, event: DirectoryTree.FileSelected ) -> None: @@ -409,9 +518,9 @@ class Loxide(App[Message]): # --------------------------------------------------------------------------- -# 3) PUBLIC ENTRYPOINT +# 3) PUBLIC ENTRYPOINT - Updated to accept attach_notification_handler # --------------------------------------------------------------------------- -def run_Loxide(api: AirlockAPIWrapper) -> None: +def run_Loxide(api: AirlockAPIWrapper, attach_notification_handler=None) -> None: global _APP_RESTART_REASON base_dir = get_base_directory() env_path = base_dir / ".env" @@ -426,6 +535,10 @@ def run_Loxide(api: AirlockAPIWrapper) -> None: _APP_RESTART_REASON = None app = Loxide(api) + # Attach the notification handler if provided + if attach_notification_handler: + attach_notification_handler(app) + try: app.run() except SystemExit as exc: @@ -453,12 +566,13 @@ def run_Loxide(api: AirlockAPIWrapper) -> None: # --------------------------------------------------------------------------- -# 4) MAIN FUNCTION +# 4) MAIN FUNCTION - Updated to get and pass attach_notification_handler # --------------------------------------------------------------------------- def main(): irtang() # Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored - setup() + # setup() now returns a function to attach the notification handler + attach_notification_handler = setup() logger = logging.getLogger(__name__) try: @@ -485,7 +599,7 @@ def main(): base_url=str(url), api_key=api_key, ) - run_Loxide(api) + run_Loxide(api, attach_notification_handler) if __name__ == "__main__": diff --git a/TUI/Screens/executionhistoryscreen.py b/TUI/Screens/executionhistoryscreen.py new file mode 100644 index 0000000..abf0648 --- /dev/null +++ b/TUI/Screens/executionhistoryscreen.py @@ -0,0 +1,639 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from datetime import datetime, timedelta +import logging +import os +from typing import List + +import pandas as pd +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Label, + Select, + Static, +) + +from models.agent import Agent +from models.execution import ExecutionHistoryRecord +from utils.configmanager import load_env + +logger = logging.getLogger(__name__) + + +class ExecutionHistoryScreen(Screen): + """ + A screen for viewing and exporting execution history for selected agents. + + This screen allows users to: + 1. Select a start date and end date using dropdown selects + 2. Fetch execution history for all selected agents + 3. View the results in a DataTable + 4. Export the results to CSV using a keybinding + + Attributes: + agents (List[Agent]): List of agents to fetch execution history for + execution_data (pd.DataFrame): Combined execution history data + working_dir (str): Directory for CSV exports + """ + + DEFAULT_CSS = """ + ExecutionHistoryScreen { + align: center top; + } + + #main_container { + width: 95%; + height: 1fr; + border: solid $primary; + padding: 1; + } + + #title { + text-style: bold; + color: $text; + text-align: center; + margin-bottom: 1; + } + + #date_container { + height: auto; + margin-bottom: 1; + } + + #start_date_row, #end_date_row { + height: auto; + align-horizontal: left; + margin-bottom: 1; + } + + .date_label { + width: 8; + margin-right: 1; + } + + .date_selector { + width: 18; + margin: 0 1; + } + + #quick_buttons_row { + height: auto; + align-horizontal: center; + margin-bottom: 1; + } + + .quick_select_btn { + margin: 0 1; + } + + #button_row { + height: auto; + align-horizontal: center; + margin-top: 1; + margin-bottom: 1; + } + + Button { + margin: 0 1; + } + + #status_label { + text-align: center; + color: $accent; + margin-bottom: 1; + } + + #results_container { + height: 1fr; + display: none; + } + + #results_button_row { + height: auto; + align-horizontal: center; + margin-bottom: 1; + } + + #history_table { + height: 1fr; + border: solid $primary; + } + + DataTable > .datatable--header { + text-style: bold; + background: $primary 20%; + } + """ + + BINDINGS = [ + Binding("escape", "close_screen", "Close"), + Binding("e", "export_csv", "Export CSV"), + Binding("q", "close_screen", "Quit"), + ] + + def __init__(self, agents: List[Agent]): + """ + Initialize the ExecutionHistoryScreen. + + Args: + agents (List[Agent]): List of agents to fetch execution history for + """ + super().__init__() + self.agents = agents + self.execution_data = pd.DataFrame() + self.working_dir = load_env("WORKING_DIR") or os.getcwd() + + # Generate dropdown options + today = datetime.now().date() + + # Month options - format is (display_text, value) + self.month_options = [ + ("January", "01"), + ("February", "02"), + ("March", "03"), + ("April", "04"), + ("May", "05"), + ("June", "06"), + ("July", "07"), + ("August", "08"), + ("September", "09"), + ("October", "10"), + ("November", "11"), + ("December", "12"), + ] + + # Day options (1-31) - format is (display_text, value) + self.day_options = [(f"{i}", f"{i:02d}") for i in range(1, 32)] + + # Year options (current year back 5 years) - format is (display_text, value) + current_year = today.year + self.year_options = [ + (str(year), str(year)) for year in range(current_year, current_year - 6, -1) + ] + + # Default dates: last 30 days + start_date = today - timedelta(days=30) + self.start_month = f"{start_date.month:02d}" + self.start_day = f"{start_date.day:02d}" + self.start_year = str(start_date.year) + + self.end_month = f"{today.month:02d}" + self.end_day = f"{today.day:02d}" + self.end_year = str(today.year) + + def compose(self) -> ComposeResult: + """Build the UI layout.""" + yield Header(show_clock=True, icon="πŸ“Š") + + with Vertical(id="main_container"): + title_text = f"Execution History - {len(self.agents)} Agent(s)" + yield Static(title_text, id="title") + + # Date selection area + with Vertical(id="date_container"): + yield Label("Select Date Range:") + + # Start date row + with Horizontal(id="start_date_row"): + yield Label("From:", classes="date_label") + yield Select( + options=self.month_options, + value=self.start_month, + id="start_month_select", + classes="date_selector", + ) + yield Select( + options=self.day_options, + value=self.start_day, + id="start_day_select", + classes="date_selector", + ) + yield Select( + options=self.year_options, + value=self.start_year, + id="start_year_select", + classes="date_selector", + ) + + # End date row + with Horizontal(id="end_date_row"): + yield Label("To:", classes="date_label") + yield Select( + options=self.month_options, + value=self.end_month, + id="end_month_select", + classes="date_selector", + ) + yield Select( + options=self.day_options, + value=self.end_day, + id="end_day_select", + classes="date_selector", + ) + yield Select( + options=self.year_options, + value=self.end_year, + id="end_year_select", + classes="date_selector", + ) + + # Quick select buttons + with Horizontal(id="quick_buttons_row"): + yield Button( + "1 Day", + id="quick_1day", + classes="quick_select_btn", + variant="default", + ) + yield Button( + "1 Week", + id="quick_1week", + classes="quick_select_btn", + variant="default", + ) + yield Button( + "30 Days", + id="quick_30days", + classes="quick_select_btn", + variant="default", + ) + + # Buttons + with Horizontal(id="button_row"): + yield Button("Fetch History", id="fetch_btn", variant="primary") + yield Button("Close", id="close_btn", variant="error") + + # Status + yield Static( + "Select date range and click 'Fetch History'", id="status_label" + ) + + # Results container (hidden initially, shown after fetch) + with Vertical(id="results_container"): + with Horizontal(id="results_button_row"): + yield Button("Export CSV", id="export_btn", variant="success") + yield Button("Back", id="back_btn", variant="default") + yield DataTable(id="history_table") + + yield Footer() + + def on_mount(self) -> None: + """Initialize the table when screen is mounted.""" + table = self.query_one("#history_table", DataTable) + table.cursor_type = "row" + table.zebra_stripes = True + + # Initially empty - will populate after fetch + logger.info(f"ExecutionHistoryScreen mounted with {len(self.agents)} agents") + + def on_select_changed(self, event: Select.Changed) -> None: + """Handle date selection changes.""" + select_id = event.select.id + + if select_id == "start_month_select": + self.start_month = event.value + logger.debug(f"Start month changed to: {self.start_month}") + elif select_id == "start_day_select": + self.start_day = event.value + logger.debug(f"Start day changed to: {self.start_day}") + elif select_id == "start_year_select": + self.start_year = event.value + logger.debug(f"Start year changed to: {self.start_year}") + elif select_id == "end_month_select": + self.end_month = event.value + logger.debug(f"End month changed to: {self.end_month}") + elif select_id == "end_day_select": + self.end_day = event.value + logger.debug(f"End day changed to: {self.end_day}") + elif select_id == "end_year_select": + self.end_year = event.value + logger.debug(f"End year changed to: {self.end_year}") + + def _set_quick_date_range(self, days: int) -> None: + """Set the date range based on quick select button.""" + today = datetime.now().date() + start_date = today - timedelta(days=days) + + # Update internal values + self.start_month = f"{start_date.month:02d}" + self.start_day = f"{start_date.day:02d}" + self.start_year = str(start_date.year) + + self.end_month = f"{today.month:02d}" + self.end_day = f"{today.day:02d}" + self.end_year = str(today.year) + + # Update the Select widgets + try: + self.query_one("#start_month_select", Select).value = self.start_month + self.query_one("#start_day_select", Select).value = self.start_day + self.query_one("#start_year_select", Select).value = self.start_year + + self.query_one("#end_month_select", Select).value = self.end_month + self.query_one("#end_day_select", Select).value = self.end_day + self.query_one("#end_year_select", Select).value = self.end_year + + self.app.notify( + f"Date range set to last {days} day(s)", + severity="information", + timeout=2, + ) + logger.info(f"Quick select: Set date range to last {days} days") + except Exception as e: + logger.error(f"Failed to update date selects: {e}") + + def _show_date_selection(self) -> None: + """Show the date selection view and hide results.""" + try: + self.query_one("#date_container").styles.display = "block" + self.query_one("#button_row").styles.display = "block" + self.query_one("#status_label").styles.display = "block" + self.query_one("#results_container").styles.display = "none" + except Exception as e: + logger.error(f"Failed to show date selection: {e}") + + def _show_results(self) -> None: + """Hide date selection view and show results.""" + try: + self.query_one("#date_container").styles.display = "none" + self.query_one("#button_row").styles.display = "none" + self.query_one("#status_label").styles.display = "none" + self.query_one("#results_container").styles.display = "block" + except Exception as e: + logger.error(f"Failed to show results: {e}") + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button clicks.""" + if event.button.id == "fetch_btn": + self._fetch_execution_history() + elif event.button.id == "export_btn": + self._export_to_csv() + elif event.button.id == "close_btn": + self.app.pop_screen() + elif event.button.id == "back_btn": + self._show_date_selection() + elif event.button.id == "quick_1day": + self._set_quick_date_range(days=1) + elif event.button.id == "quick_1week": + self._set_quick_date_range(days=7) + elif event.button.id == "quick_30days": + self._set_quick_date_range(days=30) + + def _fetch_execution_history(self) -> None: + """Fetch execution history for all selected agents.""" + status_label = self.query_one("#status_label", Static) + status_label.update("ҏ³ Fetching execution history...") + + # Disable buttons during fetch + fetch_btn = self.query_one("#fetch_btn", Button) + export_btn = self.query_one("#export_btn", Button) + fetch_btn.disabled = True + export_btn.disabled = True + + api = self.app.api + all_history = [] + + try: + # Construct dates from dropdowns + start_date_str = f"{self.start_year}-{self.start_month}-{self.start_day}" + end_date_str = f"{self.end_year}-{self.end_month}-{self.end_day}" + + # Validate dates + try: + start_dt = datetime.strptime(start_date_str, "%Y-%m-%d") + end_dt = datetime.strptime(end_date_str, "%Y-%m-%d") + except ValueError as e: + status_label.update(f"ҝŒ Invalid date: {str(e)}") + fetch_btn.disabled = False + export_btn.disabled = False + self.app.notify(f"Invalid date selected: {str(e)}", severity="error") + return + + if start_dt > end_dt: + status_label.update("ҝŒ Error: Start date must be before end date") + fetch_btn.disabled = False + export_btn.disabled = False + return + + # Fetch history for each agent + for i, agent in enumerate(self.agents): + try: + status_label.update( + f"ҏ³ Fetching history for {agent.hostname} ({i+1}/{len(self.agents)})..." + ) + + # Call API - note the API expects 'dateto' first, then 'datefrom' + history = api.history_execution( + today=end_date_str, + date_selected=start_date_str, + agent_name=agent.hostname, + ) + + if history: + # Add agent hostname to each record for identification + for record in history: + record["agent_hostname"] = agent.hostname + all_history.extend(history) + logger.info( + f"Fetched {len(history)} records for {agent.hostname}" + ) + else: + logger.info(f"No history found for {agent.hostname}") + + except Exception as e: + logger.error(f"Failed to fetch history for {agent.hostname}: {e}") + self.app.notify( + f"Warning: Failed to fetch history for {agent.hostname}", + severity="warning", + ) + + # Convert to DataFrame + if all_history: + status_label.update( + "ҏ³ Enriching execution data with hash information..." + ) + + # Normalize field names (handle API typos) + for record in all_history: + if "policver" in record and "policyver" not in record: + record["policyver"] = record.pop("policver") + + # Convert dict records to ExecutionHistoryRecord objects + execution_records = [] + for record in all_history: + try: + execution_records.append(ExecutionHistoryRecord(**record)) + except TypeError as e: + logger.warning(f"Failed to create ExecutionHistoryRecord: {e}") + # If it fails, just keep the dict + continue + + # Enrich with hash data if we have ExecutionHistoryRecord objects + if execution_records: + try: + enriched_records = ExecutionHistoryRecord.enrich_with_hashes( + api, execution_records + ) + logger.info( + f"Enriched {len(enriched_records)} records with hash data" + ) + + # Convert back to DataFrame + self.execution_data = pd.DataFrame( + [r.__dict__ for r in enriched_records] + ) + + # Flatten hash_obj if present + if ( + not self.execution_data.empty + and "hash_obj" in self.execution_data.columns + ): + hash_df = self.execution_data["hash_obj"].apply( + lambda h: ( + h.to_dict() if h and hasattr(h, "to_dict") else {} + ) + ) + self.execution_data = pd.concat( + [ + self.execution_data.drop(columns=["hash_obj"]), + hash_df, + ], + axis=1, + ) + except Exception as e: + logger.warning(f"Failed to enrich with hashes: {e}") + # Fall back to plain DataFrame + self.execution_data = pd.DataFrame(all_history) + else: + # If we couldn't create any ExecutionHistoryRecord objects, just use raw data + self.execution_data = pd.DataFrame(all_history) + + self._populate_table() + self._show_results() # Switch to results view + self.app.notify( + f"Successfully loaded {len(self.execution_data)} records", + severity="information", + ) + else: + status_label.update( + "Γ’β€žΒΉΓ―ΒΈΒ No execution history found for selected agents/dates" + ) + self.app.notify("No execution history found", severity="information") + self.execution_data = pd.DataFrame() + + except Exception as e: + logger.error(f"Error fetching execution history: {e}") + status_label.update(f"ҝŒ Error: {str(e)}") + self.app.notify(f"Failed to fetch history: {str(e)}", severity="error") + + finally: + # Re-enable buttons + fetch_btn.disabled = False + export_btn.disabled = False + + def _populate_table(self) -> None: + """Populate the DataTable with execution history data.""" + table = self.query_one("#history_table", DataTable) + table.clear(columns=True) + + if self.execution_data.empty: + return + + # Define preferred column order (your specified order) + preferred_order = [ + "policyname", + "policyver", + "hostname", + "username", + "publisher", + "filename", + "pprocess", + "gprocess", + "sha256", + "commandline", + "agent_hostname", # Our custom field + ] + + # Get available columns in preferred order, then add any remaining columns + available_cols = [] + for col in preferred_order: + if col in self.execution_data.columns: + available_cols.append(col) + + # Add any remaining columns not in preferred order + for col in self.execution_data.columns: + if col not in available_cols: + available_cols.append(col) + + # Add columns to table + for col in available_cols: + table.add_column(col, key=col) + + # Add rows + for idx, row in self.execution_data.iterrows(): + row_data = [] + for col in available_cols: + value = row[col] + # Convert to string, handle None/NaN + if pd.isna(value): + row_data.append("") + else: + row_data.append(str(value)) + table.add_row(*row_data, key=str(idx)) + + logger.info(f"Populated table with {len(self.execution_data)} rows") + + def _export_to_csv(self) -> None: + """Export the current execution data to CSV.""" + if self.execution_data.empty: + self.app.notify("No data to export", severity="warning") + return + + try: + # Create filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"execution_history_{timestamp}.csv" + filepath = os.path.join(self.working_dir, filename) + + # Export to CSV + self.execution_data.to_csv(filepath, index=False, encoding="utf-8-sig") + + self.app.notify( + f"Òœ… Exported {len(self.execution_data)} records to: {filepath}", + severity="information", + timeout=5, + ) + logger.info(f"Exported execution history to: {filepath}") + + except Exception as e: + logger.error(f"Failed to export CSV: {e}") + self.app.notify(f"Failed to export CSV: {str(e)}", severity="error") + + def action_export_csv(self) -> None: + """Keybinding action to export CSV.""" + self._export_to_csv() + + def action_close_screen(self) -> None: + """Close this screen and return to previous.""" + self.app.pop_screen() diff --git a/TUI/Widgets/agentmoveoperations.py b/TUI/Widgets/agentmoveoperations.py index bf20a82..2986423 100644 --- a/TUI/Widgets/agentmoveoperations.py +++ b/TUI/Widgets/agentmoveoperations.py @@ -29,6 +29,7 @@ from textual.widget import Widget from textual.widgets import Button, DataTable, Footer, Header, Static, TextArea from models.agent import Agent +from TUI.Screens.executionhistoryscreen import ExecutionHistoryScreen from TUI.Screens.otpworkflowscreen import OTPWorkflowScreen from TUI.Screens.policyselectorscreen import PolicySelectorScreen from TUI.Widgets.OTP_generate import OTPGenerator @@ -140,6 +141,7 @@ class AgentMoveOperations(Widget): toggle_enforcement_btn = self.query_one("#toggle_enforcement_btn", Button) other_policy_btn = self.query_one("#other_policy_btn", Button) otp_gen_btn = self.query_one("#otp_gen_btn", Button) + exec_history_btn = self.query_one("#exec_history_btn", Button) # If operation in progress, disable all if self.operation_in_progress: @@ -148,6 +150,7 @@ class AgentMoveOperations(Widget): local_approval_btn.disabled = True toggle_enforcement_btn.disabled = True other_policy_btn.disabled = True + exec_history_btn.disabled = True else: # If an operation was selected, disable if self.selected_operation: @@ -162,6 +165,9 @@ class AgentMoveOperations(Widget): other_policy_btn.disabled = ( self.selected_operation == "other_policy" ) + exec_history_btn.disabled = ( + self.selected_operation == "exec_history" + ) else: # Enable all buttons otp_gen_btn = False @@ -169,6 +175,7 @@ class AgentMoveOperations(Widget): local_approval_btn.disabled = False toggle_enforcement_btn.disabled = False other_policy_btn.disabled = False + exec_history_btn.disabled = False except NoMatches: pass @@ -199,21 +206,21 @@ class AgentMoveOperations(Widget): f"Operation: {operation_name}", f"{'=' * 50}", "", - f"βœ… Successful ({len(successful)}):", + f"Òœ… Successful ({len(successful)}):", ] if successful: for agent, result in successful: - results_lines.append(f" βœ… {agent.hostname}") + results_lines.append(f" Òœ… {agent.hostname}") else: results_lines.append(" (none)") results_lines.append("") - results_lines.append(f"❌ Failed ({len(unsuccessful)}):") + results_lines.append(f"ҝŒ Failed ({len(unsuccessful)}):") if unsuccessful: for agent, error in unsuccessful: - results_lines.append(f" ❌ {agent.hostname}: {error}") + results_lines.append(f" ҝŒ {agent.hostname}: {error}") else: results_lines.append(" (none)") @@ -248,9 +255,9 @@ class AgentMoveOperations(Widget): - Operations panel: 1/3 width - Results area: Initially hidden, shown after operation completion """ - yield Header(show_clock=True, icon="βš™οΈ") + yield Header(show_clock=True, icon="Òőℒ️") title_text = Static( - f"πŸ–₯️ Agent Operations - {len(self.agents)} device(s) selected", + f"ðŸ–Β₯️ Agent Operations - {len(self.agents)} device(s) selected", id="move_ops_title", ) title_text.styles.margin = (0, 0, 1, 0) @@ -285,37 +292,44 @@ class AgentMoveOperations(Widget): yield operations_label # Operation buttons - export_csv_btn = Button("πŸ“„ Export CSV", id="export_csv_btn") + export_csv_btn = Button("Γ°ΕΈβ€œβ€ž Export CSV", id="export_csv_btn") export_csv_btn.styles.width = "100%" export_csv_btn.styles.margin = (0, 0, 1, 0) yield export_csv_btn local_approval_btn = Button( - "βœ”οΈ Local Approval Mode", id="local_approval_btn" + "Òœ”️ Local Approval Mode", id="local_approval_btn" ) local_approval_btn.styles.width = "100%" local_approval_btn.styles.margin = (0, 0, 1, 0) yield local_approval_btn - otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn") + otp_gen_btn = Button("Γ°ΕΈΕ½Β« Generate One Time Passes", id="otp_gen_btn") otp_gen_btn.styles.width = "100%" otp_gen_btn.styles.margin = (0, 0, 1, 0) yield otp_gen_btn toggle_enforcement_btn = Button( - "πŸ”„ Toggle Audit/Enforcement", id="toggle_enforcement_btn" + "Γ°ΕΈβ€β€ž Toggle Audit/Enforcement", id="toggle_enforcement_btn" ) toggle_enforcement_btn.styles.width = "100%" toggle_enforcement_btn.styles.margin = (0, 0, 1, 0) yield toggle_enforcement_btn other_policy_btn = Button( - "πŸ”€ Move to Other Policy", id="other_policy_btn" + "🔀 Move to Other Policy", id="other_policy_btn" ) other_policy_btn.styles.width = "100%" other_policy_btn.styles.margin = (0, 0, 1, 0) yield other_policy_btn + exec_history_btn = Button( + "πŸ“Š View Execution History", id="exec_history_btn" + ) + exec_history_btn.styles.width = "100%" + exec_history_btn.styles.margin = (0, 0, 1, 0) + yield exec_history_btn + # Status label status_label = Static("", id="status_label") status_label.styles.margin = (2, 0, 0, 0) @@ -377,17 +391,17 @@ class AgentMoveOperations(Widget): pyperclip.copy(results_text.text) self.app.notify( - "πŸ“‹βœ… Results copied to clipboard!", + "Γ°ΕΈβ€œβ€ΉΓ’Ε“β€¦ Results copied to clipboard!", severity="information", timeout=2, ) except ImportError: self.app.notify( - "❌ pyperclip not installed. Run: pip install pyperclip", + "ҝŒ pyperclip not installed. Run: pip install pyperclip", severity="warning", ) except Exception as e: - self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") + self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") event.stop() elif btn_id == "export_csv_btn": self._start_export_csv_operation() @@ -407,6 +421,9 @@ class AgentMoveOperations(Widget): elif btn_id == "otp_gen_btn": self._start_OTP_gen_operation() event.stop() + elif btn_id == "exec_history_btn": + self._start_execution_history_operation() + event.stop() def _start_local_approval_operation(self) -> None: """ @@ -435,7 +452,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("βœ”οΈ Moving agents to local approval...") + status_label.update("Òœ”️ Moving agents to local approval...") # Get API from app api = self.app.api @@ -470,12 +487,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during local approval operation: {e}") - status_label.update(f"❌ Error: {str(e)}") + status_label.update(f"ҝŒ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("βœ… Operation complete!") + status_label.update("Òœ… Operation complete!") # Display results in the widget self._display_results("Local Approval Mode", successful, unsuccessful) @@ -518,9 +535,9 @@ class AgentMoveOperations(Widget): file_path = os.path.join(str(path), filename) df.to_csv(file_path, index=False) successful.append(file_path) - status_label.update(f"βœ… Exported to {file_path}") + status_label.update(f"Òœ… Exported to {file_path}") except Exception: - status_label.update("❌ Failed") + status_label.update("ҝŒ Failed") self.operation_in_progress = False @@ -564,7 +581,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("πŸ”„ Toggling enforcement mode...") + status_label.update("Γ°ΕΈβ€β€ž Toggling enforcement mode...") # Get API from app api = self.app.api @@ -600,12 +617,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during toggle enforcement operation: {e}") - status_label.update(f"❌ Error: {str(e)}") + status_label.update(f"ҝŒ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("βœ… Operation complete!") + status_label.update("Òœ… Operation complete!") # Display results in the widget self._display_results("Toggle Audit/Enforcement", successful, unsuccessful) @@ -670,7 +687,7 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error loading policies: {e}") - status_label.update(f"❌ Error: {str(e)}") + status_label.update(f"ҝŒ Error: {str(e)}") self.operation_in_progress = False self.selected_operation = "" self.app.notify(f"Failed to load policies: {str(e)}", severity="error") @@ -681,6 +698,35 @@ class AgentMoveOperations(Widget): self.app.push_screen(OTPWorkflowScreen(self.agents)) + def _start_execution_history_operation(self) -> None: + """ + Launch the execution history viewer for selected agents. + + This operation opens a new screen that allows the user to: + 1. Select a date range for execution history + 2. Fetch execution logs for all selected agents + 3. View the results in a table + 4. Export the results to CSV + + The screen is pushed onto the screen stack, allowing the user to return + to this screen when done. + """ + status_label = self.query_one("#status_label", Static) + status_label.update("Opening execution history viewer...") + + try: + # Push the execution history screen + self.app.push_screen(ExecutionHistoryScreen(self.agents)) + logger.info( + f"Opened execution history viewer for {len(self.agents)} agents" + ) + except Exception as e: + logger.error(f"Failed to open execution history viewer: {e}") + status_label.update(f"❌ Error: {str(e)}") + self.app.notify( + f"Failed to open execution history: {str(e)}", severity="error" + ) + def _execute_move_to_policy(self, target_policy) -> None: """ Execute the actual move of agents to the selected policy. diff --git a/TUI/Widgets/policytreewidget.py b/TUI/Widgets/policytreewidget.py index d51da78..c9811de 100644 --- a/TUI/Widgets/policytreewidget.py +++ b/TUI/Widgets/policytreewidget.py @@ -18,8 +18,9 @@ import logging from rich.text import Text from textual.containers import Horizontal, Vertical +from textual.message import Message from textual.widget import Widget -from textual.widgets import Input, OptionList, Static, Switch, Tree +from textual.widgets import Button, Input, OptionList, Static, Switch, Tree from textual.widgets.option_list import Option logger = logging.getLogger(__name__) @@ -28,6 +29,27 @@ logger = logging.getLogger(__name__) class PolicyTreeWidget(Widget): """Widget for displaying and searching a hierarchical policy tree.""" + class ViewExecutionHistory(Message): + """Message sent when user wants to view execution history for a device.""" + + def __init__(self, device): + super().__init__() + self.device = device + + class GenerateOTP(Message): + """Message sent when user wants to generate OTP for a device.""" + + def __init__(self, device): + super().__init__() + self.device = device + + class ToggleEnforcement(Message): + """Message sent when user wants to toggle audit/enforcement for a device.""" + + def __init__(self, device): + super().__init__() + self.device = device + def __init__(self, policies, devices): super().__init__() self.policies = policies @@ -35,6 +57,7 @@ class PolicyTreeWidget(Widget): self.last_highlighted_node = None self.leaf_counts = defaultdict(int) self.match_type = "Count" # Default to sorting by count + self.selected_device = None # Track currently selected device def compose(self): # Create the switch and its label @@ -56,6 +79,18 @@ class PolicyTreeWidget(Widget): search_box = Input( placeholder="Search policies or devices...", id="tree_search" ) + exec_history_button = Button( + "πŸ“Š Execution History", id="view_exec_history_button", disabled=True + ) + exec_history_button.styles.margin = (0, 1, 0, 0) # Right margin + + otp_button = Button("🎫 Generate OTP", id="generate_otp_button", disabled=True) + otp_button.styles.margin = (0, 1, 0, 0) # Right margin + + toggle_enforcement_button = Button( + "πŸ”„ Toggle Enforcement/Audit", id="toggle_enforcement_button", disabled=True + ) + # No right margin on last button details_pane = Static("", id="details_pane") # Layout the UI @@ -73,6 +108,12 @@ class PolicyTreeWidget(Widget): # Add the search box and details pane yield label yield search_box + # Action buttons in a horizontal row + with Horizontal() as button_row: + button_row.styles.height = "auto" + yield exec_history_button + yield otp_button + yield toggle_enforcement_button yield details_pane def on_mount(self) -> None: @@ -89,6 +130,32 @@ class PolicyTreeWidget(Widget): # Expand the root node policy_tree.root.expand() + def refresh_data(self, policies, devices): + """Refresh the widget with new data and rebuild the tree.""" + self.policies = policies + self.devices = devices + self.selected_device = None + + # Disable all buttons since selection is lost + try: + self.query_one("#view_exec_history_button", Button).disabled = True + self.query_one("#generate_otp_button", Button).disabled = True + self.query_one("#toggle_enforcement_button", Button).disabled = True + except: + pass + + # Rebuild tree with new data + self._precompute_leaf_counts() + total_leaves = sum( + self.leaf_counts.get(policy.groupid, 0) + for policy in self.policies + if policy.parent == "global-policy-settings" + ) + policy_tree = self.query_one("#policy_tree", Tree) + policy_tree.root.set_label(f"Agents in Policies: ({total_leaves})") + self._build_tree() + policy_tree.root.expand() + def _precompute_leaf_counts(self): """Precompute leaf counts for each policy group.""" device_counts = defaultdict(int) @@ -184,6 +251,9 @@ class PolicyTreeWidget(Widget): node = message.node data = node.data details_pane = self.query_one("#details_pane", Static) + exec_history_button = self.query_one("#view_exec_history_button", Button) + otp_button = self.query_one("#generate_otp_button", Button) + toggle_enforcement_button = self.query_one("#toggle_enforcement_button", Button) if self.last_highlighted_node is not None: original_label = str(self.last_highlighted_node.label).strip() @@ -198,6 +268,20 @@ class PolicyTreeWidget(Widget): node.set_label(highlighted_label) self.last_highlighted_node = node + # Check if selected node is a device (has Agent data) + from models.agent import Agent + + if data and isinstance(data, Agent): + self.selected_device = data + exec_history_button.disabled = False + otp_button.disabled = False + toggle_enforcement_button.disabled = False + else: + self.selected_device = None + exec_history_button.disabled = True + otp_button.disabled = True + toggle_enforcement_button.disabled = True + if data: details = "\n".join( f"{key}: {value}" for key, value in data.__dict__.items() @@ -287,3 +371,18 @@ class PolicyTreeWidget(Widget): option_list.remove() except: pass + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "view_exec_history_button": + if self.selected_device: + self.post_message(self.ViewExecutionHistory(self.selected_device)) + event.stop() + elif event.button.id == "generate_otp_button": + if self.selected_device: + self.post_message(self.GenerateOTP(self.selected_device)) + event.stop() + elif event.button.id == "toggle_enforcement_button": + if self.selected_device: + self.post_message(self.ToggleEnforcement(self.selected_device)) + event.stop() diff --git a/TUI/Widgets/serverlogwidget.py b/TUI/Widgets/serverlogwidget.py new file mode 100644 index 0000000..79ef132 --- /dev/null +++ b/TUI/Widgets/serverlogwidget.py @@ -0,0 +1,159 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import datetime +import logging + +from bson import ObjectId +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.widgets import Button, DataTable, Static + +from services.API import AirlockAPIWrapper + +logger = logging.getLogger(__name__) + + +def skipback(days): + """ + Generate a MongoDB ObjectId for a given number of days ago from today. + """ + adjusted_days = days + date_days_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta( + days=adjusted_days + ) + timestamp = int(date_days_ago.timestamp()) + hex_timestamp = format(timestamp, "08x") + objectid_hex = hex_timestamp + "0000000000000000" + return ObjectId(objectid_hex) + + +class ServerLogWidget(Vertical): + """Widget for displaying server activity logs in a DataTable.""" + + DEFAULT_CSS = """ + ServerLogWidget { + width: 100%; + height: 100%; + } + + ServerLogWidget #status_bar { + width: 100%; + height: auto; + background: $surface; + padding: 1; + margin-bottom: 1; + } + + ServerLogWidget DataTable { + height: 1fr; + border: solid $primary; + } + + ServerLogWidget #button_container { + width: 100%; + height: auto; + layout: horizontal; + padding: 1; + } + + ServerLogWidget Button { + margin-right: 1; + } + """ + + def __init__(self, api: AirlockAPIWrapper): + super().__init__() + self.api = api + + def compose(self) -> ComposeResult: + yield Static("Loading server logs (last 72 hours)...", id="status_bar") + yield DataTable(id="server_log_table") + with Container(id="button_container"): + yield Button("Refresh", id="refresh_button", variant="primary") + + def on_mount(self) -> None: + """Initialize the DataTable and load server logs.""" + self.load_logs() + + def load_logs(self) -> None: + """Load server logs from the API and populate the DataTable.""" + table = self.query_one("#server_log_table", DataTable) + status = self.query_one("#status_bar", Static) + + try: + status.update("⏳ Loading server logs (last 72 hours)...") + + # Create a fake checkpoint for 3 days ago (72 hours) + checkpoint = str(skipback(3)) + + # Get server logs from API + logs = self.api.server_logs(checkpoint=checkpoint) + + if not logs: + status.update("β„ΉΓ―ΒΈ No server logs found in the last 72 hours.") + table.clear(columns=True) + return + + # Clear existing data + table.clear(columns=True) + + # Add columns based on the first log entry + if logs: + first_log = logs[0] + columns = [col for col in first_log.keys() if col != "checkpoint"] + + for col in columns: + table.add_column(col, key=col) + + # Add rows in reverse order so newest entries are at the top + for log_entry in reversed(logs): + row_data = [] + for col in columns: + value = log_entry.get(col, "") + # Format datetime column to be more readable + if col == "datetime" and value: + try: + # Parse ISO format and convert to readable format + dt = datetime.datetime.fromisoformat( + str(value).replace("Z", "+00:00") + ) + value = dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + # If parsing fails, just use the original value + pass + row_data.append(str(value)) + table.add_row(*row_data) + + status.update( + f"βœ… Loaded {len(logs)} log entries from the last 72 hours" + ) + logger.info(f"Loaded {len(logs)} server log entries") + else: + status.update("β„ΉΓ―ΒΈ No log entries found.") + + except Exception as exc: + error_msg = f"❌ Error loading server logs: {exc}" + status.update(error_msg) + logger.error(f"Failed to load server logs: {exc}", exc_info=True) + table.clear(columns=True) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + button_id = event.button.id + + if button_id == "refresh_button": + self.load_logs() + event.stop() diff --git a/services/API.py b/services/API.py index 22cfa7d..d9e2d36 100644 --- a/services/API.py +++ b/services/API.py @@ -351,6 +351,14 @@ class AirlockAPIWrapper: result = self._post("/v1/getexechistory", payload) return result["response"]["exechistory"] + def server_logs(self, checkpoint: str | None = None) -> str: + """Retrieves Server Activity History Logs.""" + payload = {} + if checkpoint is not None: + payload["checkpoint"] = checkpoint + result = self._post("/v1/logging/svractivities?checkpoint", payload) + return result["response"]["svractivities"] + """ from services.API import AirlockAPIWrapper diff --git a/utils/setup.py b/utils/setup.py index f28a9a3..36cf927 100644 --- a/utils/setup.py +++ b/utils/setup.py @@ -29,6 +29,49 @@ from utils.configmanager import ( ) +class TextualNotificationHandler(logging.Handler): + """ + Custom logging handler that sends ERROR, WARNING, and CRITICAL logs + to Textual toast notifications. + """ + + def __init__(self, app): + super().__init__() + self.app = app + + def emit(self, record): + try: + # Only handle ERROR, WARNING, and CRITICAL + if record.levelno >= logging.WARNING: + # Format the message + msg = self.format(record) + + # Map log levels to Textual severity + severity_map = { + logging.WARNING: "warning", + logging.ERROR: "error", + logging.CRITICAL: "error", + } + severity = severity_map.get(record.levelno, "information") + + # Send to Textual notification + # Use call_from_thread if logging from non-main thread + try: + self.app.notify(msg, severity=severity, timeout=5) + except Exception: + # If we're not on the main thread, schedule it + try: + self.app.call_from_thread( + self.app.notify, msg, severity=severity, timeout=5 + ) + except Exception: + # Silently fail to avoid breaking the logging system + pass + except Exception: + # Silently fail to avoid breaking the logging system + pass + + def get_base_directory() -> Path: system = platform.system() home = Path.home() @@ -44,38 +87,31 @@ def configure_logging(log_dir: Path, log_level: str = "INFO"): log_file = log_dir / "Loxide.log" config = { - "version": 1, # Required key for dictConfig format version - "disable_existing_loggers": False, # Keeps existing loggers active + "version": 1, + "disable_existing_loggers": False, "formatters": { "detailed": { "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - # Includes timestamp, logger name, level, and message - }, - "simple": { - "format": "%(levelname)s - %(message)s" - # Minimal format for console output }, + "simple": {"format": "%(levelname)s - %(message)s"}, + "toast": {"format": "%(name)s: %(message)s"}, # Simpler format for toasts }, "handlers": { "file": { "class": "logging.handlers.TimedRotatingFileHandler", "filename": str(log_file), - "when": "midnight", # Rotate logs at midnight - "interval": 1, # Every 1 day - "backupCount": 7, # Keep 7 days of logs - "encoding": "utf-8", # Ensure UTF-8 encoding - "level": "DEBUG", # Always log DEBUG and above to file - "formatter": "detailed", # Use detailed format - }, - "console": { - "class": "logging.StreamHandler", - "level": log_level.upper(), # System-configured level for console - "formatter": "simple", # Use simple format + "when": "midnight", + "interval": 1, + "backupCount": 7, + "encoding": "utf-8", + "level": "DEBUG", + "formatter": "detailed", }, + # REMOVED console handler - it interferes with Textual TUI }, "root": { - "level": "DEBUG", # Root logger level - "handlers": ["file", "console"], # Attach both handlers + "level": "DEBUG", + "handlers": ["file"], # Only use file handler, not console }, } @@ -96,6 +132,18 @@ def configure_logging(log_dir: Path, log_level: str = "INFO"): logging.config.dictConfig(config) logging.getLogger().debug("βœ… Logging configured.") + # Return a function to attach the notification handler once the app is created + def attach_notification_handler(app): + """Attach the Textual notification handler to the root logger.""" + handler = TextualNotificationHandler(app) + handler.setLevel(logging.WARNING) # Only WARNING and above + formatter = logging.Formatter("%(name)s: %(message)s") + handler.setFormatter(formatter) + logging.getLogger().addHandler(handler) + logging.getLogger().debug("βœ… Textual notification handler attached.") + + return attach_notification_handler + def setup(): """ @@ -105,6 +153,9 @@ def setup(): 3. Load user config (mutable, from user_config.json) 4. Configure logging 5. Set up .env with WORKING_DIR only + + Returns: + attach_notification_handler: Function to attach notification handler to TUI app """ base_dir = get_base_directory() dirs = { @@ -122,7 +173,7 @@ def setup(): # Configure logging with system-defined log level log_level = get_system_value("LOG_LEVEL", str, "INFO") - configure_logging(dirs["logs"], log_level) + attach_handler = configure_logging(dirs["logs"], log_level) # Load user config (mutable) load_user_config(dirs["config"]) @@ -145,7 +196,6 @@ def setup(): "Approved": [], "Needs_Review": ["Review_First", "Review_Second", "HTML"], "Preflight": ["HTML"], - "Archived": [], } for folder_name, subfolders in folders_structure.items(): @@ -158,3 +208,6 @@ def setup(): logging.debug(f"'{subfolder}' subfolder created at: {subfolder_path}") logging.info("βœ… Setup complete") + + # Return the attach handler function + return attach_handler -- 2.34.1 From 66eb101c5d1c26a76132b694fbc508fc8ec53228 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Tue, 16 Dec 2025 17:07:35 -0500 Subject: [PATCH 23/25] Fixed Bitbake --- TUI/Widgets/agentmoveoperations.py | 48 +++++++++++++++--------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/TUI/Widgets/agentmoveoperations.py b/TUI/Widgets/agentmoveoperations.py index 2986423..15cfc96 100644 --- a/TUI/Widgets/agentmoveoperations.py +++ b/TUI/Widgets/agentmoveoperations.py @@ -206,21 +206,21 @@ class AgentMoveOperations(Widget): f"Operation: {operation_name}", f"{'=' * 50}", "", - f"Òœ… Successful ({len(successful)}):", + f"βœ… Successful ({len(successful)}):", ] if successful: for agent, result in successful: - results_lines.append(f" Òœ… {agent.hostname}") + results_lines.append(f" βœ… {agent.hostname}") else: results_lines.append(" (none)") results_lines.append("") - results_lines.append(f"ҝŒ Failed ({len(unsuccessful)}):") + results_lines.append(f"❌ Failed ({len(unsuccessful)}):") if unsuccessful: for agent, error in unsuccessful: - results_lines.append(f" ҝŒ {agent.hostname}: {error}") + results_lines.append(f" ❌ {agent.hostname}: {error}") else: results_lines.append(" (none)") @@ -255,9 +255,9 @@ class AgentMoveOperations(Widget): - Operations panel: 1/3 width - Results area: Initially hidden, shown after operation completion """ - yield Header(show_clock=True, icon="Òőℒ️") + yield Header(show_clock=True, icon="βš™οΈ") title_text = Static( - f"ðŸ–Β₯️ Agent Operations - {len(self.agents)} device(s) selected", + f"πŸ–₯️ Agent Operations - {len(self.agents)} device(s) selected", id="move_ops_title", ) title_text.styles.margin = (0, 0, 1, 0) @@ -292,32 +292,32 @@ class AgentMoveOperations(Widget): yield operations_label # Operation buttons - export_csv_btn = Button("Γ°ΕΈβ€œβ€ž Export CSV", id="export_csv_btn") + export_csv_btn = Button("πŸ“„ Export CSV", id="export_csv_btn") export_csv_btn.styles.width = "100%" export_csv_btn.styles.margin = (0, 0, 1, 0) yield export_csv_btn local_approval_btn = Button( - "Òœ”️ Local Approval Mode", id="local_approval_btn" + "βœ”οΈ Local Approval Mode", id="local_approval_btn" ) local_approval_btn.styles.width = "100%" local_approval_btn.styles.margin = (0, 0, 1, 0) yield local_approval_btn - otp_gen_btn = Button("Γ°ΕΈΕ½Β« Generate One Time Passes", id="otp_gen_btn") + otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn") otp_gen_btn.styles.width = "100%" otp_gen_btn.styles.margin = (0, 0, 1, 0) yield otp_gen_btn toggle_enforcement_btn = Button( - "Γ°ΕΈβ€β€ž Toggle Audit/Enforcement", id="toggle_enforcement_btn" + "πŸ”„ Toggle Audit/Enforcement", id="toggle_enforcement_btn" ) toggle_enforcement_btn.styles.width = "100%" toggle_enforcement_btn.styles.margin = (0, 0, 1, 0) yield toggle_enforcement_btn other_policy_btn = Button( - "🔀 Move to Other Policy", id="other_policy_btn" + "πŸ”€ Move to Other Policy", id="other_policy_btn" ) other_policy_btn.styles.width = "100%" other_policy_btn.styles.margin = (0, 0, 1, 0) @@ -391,17 +391,17 @@ class AgentMoveOperations(Widget): pyperclip.copy(results_text.text) self.app.notify( - "Γ°ΕΈβ€œβ€ΉΓ’Ε“β€¦ Results copied to clipboard!", + "πŸ“‹βœ… Results copied to clipboard!", severity="information", timeout=2, ) except ImportError: self.app.notify( - "ҝŒ pyperclip not installed. Run: pip install pyperclip", + "❌ pyperclip not installed. Run: pip install pyperclip", severity="warning", ) except Exception as e: - self.app.notify(f"ҝŒ Failed to copy: {str(e)}", severity="error") + self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") event.stop() elif btn_id == "export_csv_btn": self._start_export_csv_operation() @@ -452,7 +452,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("Òœ”️ Moving agents to local approval...") + status_label.update("βœ”οΈ Moving agents to local approval...") # Get API from app api = self.app.api @@ -487,12 +487,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during local approval operation: {e}") - status_label.update(f"ҝŒ Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("Òœ… Operation complete!") + status_label.update("βœ… Operation complete!") # Display results in the widget self._display_results("Local Approval Mode", successful, unsuccessful) @@ -535,9 +535,9 @@ class AgentMoveOperations(Widget): file_path = os.path.join(str(path), filename) df.to_csv(file_path, index=False) successful.append(file_path) - status_label.update(f"Òœ… Exported to {file_path}") + status_label.update(f"βœ… Exported to {file_path}") except Exception: - status_label.update("ҝŒ Failed") + status_label.update("❌ Failed") self.operation_in_progress = False @@ -581,7 +581,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("Γ°ΕΈβ€β€ž Toggling enforcement mode...") + status_label.update("πŸ”„ Toggling enforcement mode...") # Get API from app api = self.app.api @@ -617,12 +617,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during toggle enforcement operation: {e}") - status_label.update(f"ҝŒ Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("Òœ… Operation complete!") + status_label.update("βœ… Operation complete!") # Display results in the widget self._display_results("Toggle Audit/Enforcement", successful, unsuccessful) @@ -687,7 +687,7 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error loading policies: {e}") - status_label.update(f"ҝŒ Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False self.selected_operation = "" self.app.notify(f"Failed to load policies: {str(e)}", severity="error") @@ -722,7 +722,7 @@ class AgentMoveOperations(Widget): ) except Exception as e: logger.error(f"Failed to open execution history viewer: {e}") - status_label.update(f"❌ Error: {str(e)}") + status_label.update(f"ҝŒ Error: {str(e)}") self.app.notify( f"Failed to open execution history: {str(e)}", severity="error" ) -- 2.34.1 From 53f0b548b0e15e9c384543d8f36e0f0af53cc21d Mon Sep 17 00:00:00 2001 From: brotoskyj Date: Wed, 17 Dec 2025 11:02:13 -0500 Subject: [PATCH 24/25] Bug Fixes Changed days to days.to_string() to fix type confusion closes #47 --- airlock_libs/Cargo.lock | 2 +- airlock_libs/Cargo.toml | 2 +- airlock_libs/pyproject.toml | 2 +- airlock_libs/src/services.rs | 2 +- requirements.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index 4b06811..e2386f3 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "6.1.0" +version = "6.1.1" dependencies = [ "chrono", "crossbeam", diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 9bbf056..cf49ac1 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "6.1.0" +version = "6.1.1" edition = "2024" [dependencies] diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index dcf034e..5ed6be4 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "6.1.0" +version = "6.1.1" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index c0b6cc2..c567f41 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -180,7 +180,7 @@ pub fn pull_policy_exec_histories( .unwrap() .enable_steady_tick(std::time::Duration::from_millis(100)); let span: opentelemetry::trace::SpanRef<'_> = cx.span(); - span.set_attribute(KeyValue::new("Days", days)); + span.set_attribute(KeyValue::new("Days", days.to_string())); span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); loop { let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { diff --git a/requirements.txt b/requirements.txt index 3176acd..30d5f35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,4 +23,4 @@ pyperclip==1.11.0 # Custom/Private packages --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==6.1.0 \ No newline at end of file +airlock_libs==6.1.1 \ No newline at end of file -- 2.34.1 From a7b659c9515ccf4514008448a83471115da21495 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Wed, 17 Dec 2025 12:38:31 -0500 Subject: [PATCH 25/25] policyprepworkflow: enhancements and fixes - Added row copy functionality (Ctrl+C) - Improved row selection visual contrast - Fixed data state issues when navigating between stages Server Log tab - Added new Server Log tab to the main application - Implemented live filtering with wildcard support - Enabled auto-refresh capability Multiagent selector - Added file loading support for device lists --- TUI/Screens/policyprepworkflowscreen.py | 751 +++++++++++++++++++++--- TUI/Widgets/multiagentselector.py | 132 ++++- TUI/Widgets/serverlogwidget.py | 110 +++- 3 files changed, 889 insertions(+), 104 deletions(-) diff --git a/TUI/Screens/policyprepworkflowscreen.py b/TUI/Screens/policyprepworkflowscreen.py index ec9b03a..3b068b8 100644 --- a/TUI/Screens/policyprepworkflowscreen.py +++ b/TUI/Screens/policyprepworkflowscreen.py @@ -33,7 +33,6 @@ from models.policy import Allowlist, Policy from services.API import AirlockAPIWrapper from TUI.Widgets.policyselector import PolicySelector from utils.configmanager import get_system_list, get_system_value, load_env -from utils.utils import formatHTML logger = logging.getLogger(__name__) @@ -64,12 +63,20 @@ class PolicyPrepWorkflowScreen(Screen): DEFAULT_CSS = """ DataTable > .datatable--row.selected { - background: $primary 30%; + background: $accent; + color: $background; } - + + /* Highlighted cursor row */ DataTable:focus > .datatable--cursor { background: $secondary 20%; } + + /* When a row is both selected and has cursor, selection wins */ + DataTable:focus > .datatable--cursor.selected { + background: $accent; + color: $background; + } #workflow_title { text-style: bold; @@ -117,6 +124,7 @@ class PolicyPrepWorkflowScreen(Screen): Binding("q", "main_menu", "Main Menu"), Binding("f", "open_folder", "Open Folder"), Binding("d", "delete_rows", "Delete Selected"), + Binding("c", "copy_rows", "Copy Selected"), Binding("a", "select_all", "Select All"), Binding("n", "select_none", "Select None"), Binding("space", "toggle_selection", "Toggle Selection", show=False), @@ -216,20 +224,21 @@ class PolicyPrepWorkflowScreen(Screen): status_widget = self.query_one("#workflow_status", Static) stage_messages = { + "introduction": "Step 0: Workflow Introduction", "select_source": "Step 1: Select Source Policies", "select_destination": "Step 2: Select Destination Policy", "select_allowlist": "Step 3: Select Destination Allowlist", "fetch_data": "Step 4: Fetch Execution History", - "fetching": "Fetching and sorting execution data...", + "fetching": "Processing: Fetching and sorting execution data...", "first_review": "Step 5: First Manual Review", - "build_paths": "Step 6: Building Path Exclusions", - "second_review": "Step 7: Second Manual Review", - "test": "Step 8: Test - Preview Changes", - "liftoff": "Step 9: Liftoff - Apply Changes", - "complete": "Workflow Complete", + "building_paths": "Processing: Building path exclusions and publishers...", + "second_review": "Step 6: Second Manual Review", + "test": "Step 7: Test - Preview Changes", + "liftoff": "Step 8: Liftoff - Apply Changes", + "complete": "βœ… Workflow Complete!", } - status_widget.update(stage_messages.get(self.workflow_stage, "Unknown Stage")) + status_widget.update(stage_messages.get(self.workflow_stage, "Processing...")) def _update_checklist(self) -> None: """Update the preparation checklist display.""" @@ -395,7 +404,7 @@ class PolicyPrepWorkflowScreen(Screen): " πŸ“‹ Step 1: Select source policies (data collection)\n" " 🎯 Step 2: Select destination policy (where changes go)\n" " πŸ“ Step 3: Select destination allowlist\n" - " πŸ“Š Step 4: Fetch execution data (may take 1-2 minutes)\n" + " πŸ“Š Step 4: Fetch execution data (5-30 minutes, depending on policy size)\n" " βœ… Step 5: Review approved/needs review executions\n" " πŸ“ Step 6: Review path exclusions and publishers\n" " πŸ” Step 7: Preview changes before applying\n" @@ -405,7 +414,9 @@ class PolicyPrepWorkflowScreen(Screen): content.mount(steps) # Time estimate - estimate = Static("⏱️ Estimated Time: 15-30 minutes depending on data size") + estimate = Static( + "⏱️ Estimated Time: 1-3 hours for large policies (policies with 100k+ executions may take longer)" + ) estimate.styles.margin = (1, 2) estimate.styles.color = "cyan" content.mount(estimate) @@ -479,6 +490,13 @@ class PolicyPrepWorkflowScreen(Screen): content.mount(table) + # CRITICAL: Prevent default first-row selection + # Move focus away from the table so no row is highlighted initially + try: + content.focus() + except Exception as e: + logger.debug(f"Could not clear table focus: {e}") + # Control buttons control_container = Horizontal() control_container.styles.height = "auto" @@ -663,7 +681,7 @@ class PolicyPrepWorkflowScreen(Screen): # Show notification that fetch is starting self.app.notify( - "Starting data fetch - this may take several minutes for large policies", + "Starting data fetch - this may take 5-30 minutes for large policies with millions of executions", severity="information", timeout=5, ) @@ -768,18 +786,18 @@ class PolicyPrepWorkflowScreen(Screen): f"{len(needs_review)} needs review, {len(unknown)} unknown" ) - # Store the data - self.approved_df = ( + # Store the data - convert all unhashable objects to strings + self.approved_df = self._sanitize_dataframe( pd.DataFrame([r.__dict__ for r in approved]) if approved else pd.DataFrame() ) - self.unapproved_df = ( + self.unapproved_df = self._sanitize_dataframe( pd.DataFrame([r.__dict__ for r in unapproved]) if unapproved else pd.DataFrame() ) - self.needs_review_df = ( + self.needs_review_df = self._sanitize_dataframe( pd.DataFrame([r.__dict__ for r in needs_review]) if needs_review else pd.DataFrame() @@ -798,17 +816,50 @@ class PolicyPrepWorkflowScreen(Screen): self.app.notify(f"Failed to fetch data: {str(e)}", severity="error") self._show_fetch_data() + def _sanitize_dataframe(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Convert any unhashable objects (like custom Hash objects) to strings. + This prevents 'unhashable type' errors in pandas operations like drop_duplicates(). + + Args: + df: DataFrame that may contain unhashable objects + + Returns: + Sanitized DataFrame with all objects converted to hashable types + """ + if df.empty: + return df + + df = df.copy() + for col in df.columns: + if df[col].dtype == "object": + try: + # Check if column contains unhashable custom objects + sample = df[col].iloc[0] if len(df) > 0 else None + if sample is not None: + # Try to hash it - if it fails, convert to string + try: + hash(sample) + except TypeError: + # Unhashable type - convert entire column to string + df[col] = df[col].astype(str) + logger.debug( + f"Converted unhashable column '{col}' to strings" + ) + except Exception as e: + logger.debug(f"Error checking column '{col}': {e}") + + return df + def _save_fetched_data(self) -> None: - """Save fetched data to CSV and HTML files.""" + """Save fetched data to CSV files.""" if not self.source_policies: return policy_name = self.source_policies[0].name review_dir = os.path.join(self.working_dir, "Needs_Review", "Review_First") - html_dir = os.path.join(self.working_dir, "Needs_Review", "HTML") os.makedirs(review_dir, exist_ok=True) - os.makedirs(html_dir, exist_ok=True) # Save each category categories = { @@ -822,10 +873,8 @@ class PolicyPrepWorkflowScreen(Screen): csv_path = os.path.join( review_dir, f"{policy_name}_{label}_executions.csv" ) - html_path = os.path.join(html_dir, f"{policy_name}_{label}.html") df.to_csv(csv_path, index=False) - formatHTML(df, html_path) logger.info(f"Saved {label} executions to {csv_path}") @@ -899,10 +948,51 @@ class PolicyPrepWorkflowScreen(Screen): self._creating_review_table = True + # Disable all tab buttons to prevent race conditions + self._disable_tab_buttons() + try: self._show_review_table_impl(table_type) finally: self._creating_review_table = False + # Re-enable tab buttons after table is created + self._enable_tab_buttons() + + def _disable_tab_buttons(self) -> None: + """Disable all tab switching buttons to prevent race conditions.""" + try: + content = self.query_one("#content_area", Vertical) + tab_buttons = content.query("Button") + for btn in tab_buttons: + if btn.id in [ + "show_approved_tab", + "show_needs_review_tab", + "show_paths_tab", + "show_publishers_tab", + "show_remaining_tab", + ]: + btn.disabled = True + logger.debug(f"Disabled button: {btn.id}") + except Exception as e: + logger.debug(f"Error disabling tab buttons: {e}") + + def _enable_tab_buttons(self) -> None: + """Re-enable all tab switching buttons.""" + try: + content = self.query_one("#content_area", Vertical) + tab_buttons = content.query("Button") + for btn in tab_buttons: + if btn.id in [ + "show_approved_tab", + "show_needs_review_tab", + "show_paths_tab", + "show_publishers_tab", + "show_remaining_tab", + ]: + btn.disabled = False + logger.debug(f"Enabled button: {btn.id}") + except Exception as e: + logger.debug(f"Error enabling tab buttons: {e}") def _show_review_table_impl(self, table_type: str) -> None: """Internal implementation of _show_review_table.""" @@ -937,41 +1027,52 @@ class PolicyPrepWorkflowScreen(Screen): self.needs_review_df = df # Remove the SPECIFIC table we're about to create if it exists + # Buttons are now disabled during creation, so this should be quick try: existing_specific = content.query_one(f"#{table_id}", DataTable) if existing_specific: logger.debug(f"Removing existing table with ID: {table_id}") existing_specific.remove() + content.refresh(layout=True) except Exception as e: - logger.debug( - f"No existing table with ID {table_id} found (this is normal): {e}" - ) + # Table doesn't exist - good! + logger.debug(f"No existing table with ID {table_id} found: {e}") # Remove ALL existing DataTables to be safe try: existing_tables = content.query("DataTable") - logger.debug(f"Found {len(existing_tables)} existing tables to remove") - for table in existing_tables: - logger.debug(f"Removing table: {table.id}") - table.remove() + if existing_tables: + logger.debug(f"Found {len(existing_tables)} existing tables to remove") + for table in existing_tables: + logger.debug(f"Removing table: {table.id}") + try: + table.remove() + except Exception as e: + logger.debug(f"Error removing table {table.id}: {e}") + content.refresh(layout=True) except Exception as e: - logger.debug(f"Error removing existing tables: {e}") + logger.debug(f"Error querying/removing tables: {e}") - # Remove existing instruction and help text (they accumulate without removal) - # Remove ALL Static widgets - they're just text that needs to be replaced + # Remove existing instruction and help text try: existing_statics = content.query("Static") logger.debug( f"Found {len(existing_statics)} existing Static widgets to remove" ) for static in existing_statics: - static.remove() + try: + static.remove() + except Exception as e: + logger.debug(f"Error removing Static: {e}") except Exception as e: logger.debug(f"Error removing Static widgets: {e}") - # Force a refresh to ensure removals are processed + # Final refresh to ensure all removals are processed try: - content.refresh() + content.refresh(layout=True) + import time + + time.sleep(0.2) # Give DOM time to process removals (200ms) except Exception as e: logger.debug(f"Error refreshing content: {e}") @@ -998,29 +1099,75 @@ class PolicyPrepWorkflowScreen(Screen): # Help text (no ID needed) help_text = Static( "Click to toggle, 'r' for range select (click start, press 'r', click end)\n" - "Space to toggle cursor row, 'd' to delete, 'a' select all, arrows navigate" + "Space to toggle cursor row, 'd' to delete, 'c' to copy, 'a' select all, arrows navigate" ) help_text.styles.margin = (0, 1, 1, 1) help_text.styles.text_style = "dim" content.mount(help_text) - # CRITICAL: Check if table already exists in content (should not happen after removal above) + # Final safety check - WAIT until table is confirmed gone + # Use longer waits (1-2 seconds) to ensure DOM has time to process + max_wait_attempts = 5 + for wait_attempt in range(max_wait_attempts): + try: + existing_check = content.query_one(f"#{table_id}", DataTable) + if existing_check: + logger.warning( + f"Table {table_id} still exists (attempt {wait_attempt + 1}/{max_wait_attempts}). Removing and waiting..." + ) + existing_check.remove() + content.refresh(layout=True) + import time + + time.sleep(0.5) # Wait 0.5s for DOM to process + else: + # Table is gone, break out + logger.debug( + f"Table {table_id} confirmed removed after {wait_attempt} attempts" + ) + break + except Exception: + # Good - table doesn't exist, break out + logger.debug(f"Table {table_id} not found (good)") + break + + # Final verification - if table STILL exists after all attempts, force remove it and wait longer try: - existing_check = content.query_one(f"#{table_id}", DataTable) - if existing_check: + final_check = content.query_one(f"#{table_id}", DataTable) + if final_check: logger.error( - f"Table {table_id} STILL EXISTS after removal! This should not happen." + f"CRITICAL: Table {table_id} still exists after {max_wait_attempts} attempts!" ) - # Don't create a new one - just return - return + final_check.remove() + content.refresh(layout=True) + import time + + time.sleep(1.0) # Wait a full second + + # Check one more time + try: + still_there = content.query_one(f"#{table_id}", DataTable) + if still_there: + # This should never happen - log and skip mounting + logger.error( + f"FATAL: Cannot remove {table_id} even after 1 second wait. Skipping mount to prevent DuplicateIds." + ) + self.app.notify( + "Table refresh failed. Please try again.", severity="error" + ) + return + except Exception: + # Good - finally gone + logger.info(f"Table {table_id} finally removed after extended wait") + pass except Exception: - # Good - table doesn't exist, proceed with creation + # Good - table doesn't exist pass # Create the review table review_table = DataTable(id=table_id) review_table.styles.height = "40vh" # Increased since we removed button rows - review_table.cursor_type = "row" + review_table.cursor_type = "row" # Need row cursor for clicking/navigation review_table.zebra_stripes = True # Specified columns in order @@ -1055,10 +1202,13 @@ class PolicyPrepWorkflowScreen(Screen): final_check = content.query_one(f"#{table_id}", DataTable) if final_check: logger.warning( - f"Table {table_id} still exists after removal attempts! Forcing removal..." + f"Table {table_id} still exists. Waiting and removing..." ) final_check.remove() - content.refresh() + content.refresh(layout=True) + import time + + time.sleep(0.05) except Exception: # Good - table doesn't exist pass @@ -1066,6 +1216,17 @@ class PolicyPrepWorkflowScreen(Screen): content.mount(review_table) logger.debug(f"Successfully mounted {table_id} with {len(df)} rows") + # CRITICAL: Prevent default first-row selection + # Textual auto-focuses new DataTables, causing first row to be selected + # Solution: temporarily disable focus, then re-enable + try: + review_table.can_focus = False + # Schedule re-enabling focus after UI settles + self.set_timer(0.1, lambda: setattr(review_table, "can_focus", True)) + logger.debug("Disabled initial table focus to prevent default selection") + except Exception as e: + logger.debug(f"Could not prevent default focus: {e}") + # Row count display only (removed Select All, Clear, Delete buttons) try: control_container = content.query_one("#review_controls", Horizontal) @@ -1179,6 +1340,71 @@ class PolicyPrepWorkflowScreen(Screen): else: self.app.notify(f"Deleted {removed_count} rows", severity="information") + def _copy_selected_rows(self) -> None: + """Copy selected rows to clipboard as tab-separated values.""" + if not hasattr(self, "selected_rows") or not self.selected_rows: + self.app.notify("No rows selected to copy", severity="warning") + return + + # Determine which dataframe to use + if self.current_review_type == "approved": + df = self.approved_df + else: + df = self.needs_review_df + + if df is None: + return + + # Get selected rows + indices_to_copy = [int(idx) for idx in self.selected_rows] + selected_df = df.loc[df.index.isin(indices_to_copy)] + + if selected_df.empty: + self.app.notify("No valid rows to copy", severity="warning") + return + + # Convert to dictionary format (list of dicts) + try: + # Convert DataFrame to list of dictionaries + rows_as_dicts = selected_df.to_dict("records") + + # Format as Python dictionary representation + import json + + dict_data = json.dumps(rows_as_dicts, indent=2) + + # Copy to clipboard + import platform + import subprocess + + if platform.system() == "Windows": + # Windows clipboard + subprocess.run(["clip"], input=dict_data.encode("utf-8"), check=True) + elif platform.system() == "Darwin": + # macOS clipboard + subprocess.run(["pbcopy"], input=dict_data.encode("utf-8"), check=True) + else: + # Linux clipboard (try xclip first, then xsel) + try: + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=dict_data.encode("utf-8"), + check=True, + ) + except FileNotFoundError: + subprocess.run( + ["xsel", "--clipboard", "--input"], + input=dict_data.encode("utf-8"), + check=True, + ) + + self.app.notify( + f"Copied {len(selected_df)} rows as JSON", severity="information" + ) + except Exception as e: + logger.error(f"Failed to copy to clipboard: {e}") + self.app.notify(f"Failed to copy: {str(e)}", severity="error") + def _show_path_building_screen(self) -> None: """Show loading screen before building paths and publishers.""" self.workflow_stage = "building_paths" @@ -1218,27 +1444,40 @@ class PolicyPrepWorkflowScreen(Screen): if not self.source_policies: raise ValueError("No source policies selected") - policy_name = self.source_policies[0].name - approved_path = os.path.join( - self.working_dir, "Approved", f"{policy_name}_approved_executions.csv" - ) - review_path = os.path.join( - self.working_dir, - "Approved", - f"{policy_name}_needs_review_executions.csv", + # CRITICAL FIX: Use current in-memory DataFrames, not stale CSV files + # This ensures that any deletions made in the review step are reflected + df1 = self.approved_df if self.approved_df is not None else pd.DataFrame() + df2 = ( + self.needs_review_df + if self.needs_review_df is not None + else pd.DataFrame() ) - # Load approved files - df1 = ( - pd.read_csv(approved_path) - if os.path.exists(approved_path) - else pd.DataFrame() - ) - df2 = ( - pd.read_csv(review_path) - if os.path.exists(review_path) - else pd.DataFrame() - ) + # If DataFrames are empty, try loading from saved files as fallback + if df1.empty and df2.empty: + logger.info("DataFrames empty, attempting to load from saved files...") + policy_name = self.source_policies[0].name + approved_path = os.path.join( + self.working_dir, + "Approved", + f"{policy_name}_approved_executions.csv", + ) + review_path = os.path.join( + self.working_dir, + "Approved", + f"{policy_name}_needs_review_executions.csv", + ) + + df1 = ( + pd.read_csv(approved_path) + if os.path.exists(approved_path) + else pd.DataFrame() + ) + df2 = ( + pd.read_csv(review_path) + if os.path.exists(review_path) + else pd.DataFrame() + ) if df1.empty and df2.empty: self.app.notify( @@ -1314,6 +1553,24 @@ class PolicyPrepWorkflowScreen(Screen): self.publishers_df = publist + # Sort publishers alphabetically + if ( + not self.publishers_df.empty + and "publisher" in self.publishers_df.columns + ): + self.publishers_df = self.publishers_df.sort_values( + by="publisher", ascending=True + ).reset_index(drop=True) + + # Sort publishers alphabetically + if ( + not self.publishers_df.empty + and "publisher" in self.publishers_df.columns + ): + self.publishers_df = self.publishers_df.sort_values( + by="publisher", ascending=True + ).reset_index(drop=True) + # Save to Review_Second folder self._save_path_data() @@ -1435,7 +1692,7 @@ class PolicyPrepWorkflowScreen(Screen): result = pd.DataFrame(new_rows).drop(columns=["group_key"]) logger.info( - f"_split_filepaths_grouped: Processed {len(df)} rows β†’ {len(result)} rows with metadata" + f"_split_filepaths_grouped: Processed {len(df)} rows Ò†’ {len(result)} rows with metadata" ) return result @@ -1563,10 +1820,8 @@ class PolicyPrepWorkflowScreen(Screen): policy_name = self.source_policies[0].name review_dir = os.path.join(self.working_dir, "Needs_Review", "Review_Second") - html_dir = os.path.join(self.working_dir, "Needs_Review", "HTML") os.makedirs(review_dir, exist_ok=True) - os.makedirs(html_dir, exist_ok=True) # Save each dataframe dataframes = { @@ -1579,13 +1834,27 @@ class PolicyPrepWorkflowScreen(Screen): for name, df in dataframes.items(): if df is not None and not df.empty: csv_path = os.path.join(review_dir, f"{policy_name}_{name}.csv") - html_path = os.path.join(html_dir, f"{policy_name}_{name}.html") df.to_csv(csv_path, index=False) - formatHTML(df, html_path) logger.info(f"Saved {name} to {csv_path}") + def _show_fetch_data_after_clearing_paths(self) -> None: + """Navigate back to fetch data screen and clear path data for regeneration.""" + logger.info( + "Going back to first review - clearing path data to force regeneration" + ) + # Clear path-related dataframes so they get regenerated with current data + self.primary_paths_df = None + self.secondary_paths_df = None + self.publishers_df = None + self.remaining_hashes_df = None + # Reset path review tracking + self.paths_tab_reviewed = False + self.publishers_tab_reviewed = False + # Now show the fetch data screen + self._show_fetch_data() + def _show_path_results(self) -> None: """Show the results of path building.""" logger.debug("=== _show_path_results called ===") @@ -1652,10 +1921,15 @@ class PolicyPrepWorkflowScreen(Screen): self._creating_path_table = True + # Disable all tab buttons to prevent race conditions + self._disable_tab_buttons() + try: self._show_path_review_table_impl(table_type) finally: self._creating_path_table = False + # Re-enable tab buttons after table is created + self._enable_tab_buttons() def _show_path_review_table_impl(self, table_type: str) -> None: """Internal implementation of _show_path_review_table.""" @@ -1813,7 +2087,7 @@ class PolicyPrepWorkflowScreen(Screen): if table_type != "remaining": help_text = Static( "Click to toggle, 'r' for range select (click start, press 'r', click end)\n" - "Space to toggle cursor row, 'd' to delete, 'a' select all, arrows navigate" + "Space to toggle cursor row, 'd' to delete, 'c' to copy, 'a' select all, arrows navigate" ) else: help_text = Static( @@ -1827,7 +2101,7 @@ class PolicyPrepWorkflowScreen(Screen): # Create the review table review_table = DataTable(id=table_id) review_table.styles.height = "35vh" # Increased since we removed button rows - review_table.cursor_type = "row" + review_table.cursor_type = "row" # Need row cursor for clicking/navigation review_table.zebra_stripes = True # Add columns - checkbox first, then data columns @@ -1854,19 +2128,79 @@ class PolicyPrepWorkflowScreen(Screen): logger.debug(f"About to mount {table_id}") - # Final safety check before mounting table + # Final safety check - WAIT until table is confirmed gone before mounting + # Use longer waits (1-2 seconds) to ensure DOM has time to process + max_wait_attempts = 5 + for wait_attempt in range(max_wait_attempts): + try: + existing_check = content.query_one(f"#{table_id}", DataTable) + if existing_check: + logger.warning( + f"Table {table_id} still exists (attempt {wait_attempt + 1}/{max_wait_attempts}). Removing and waiting..." + ) + existing_check.remove() + content.refresh(layout=True) + import time + + time.sleep(0.5) # Wait 0.5s for DOM to process + else: + # Table is gone, break out + logger.debug( + f"Table {table_id} confirmed removed after {wait_attempt} attempts" + ) + break + except Exception: + # Good - table doesn't exist, break out + logger.debug(f"Table {table_id} not found (good)") + break + + # Final verification - if table STILL exists after all attempts, force remove it and wait longer try: final_check = content.query_one(f"#{table_id}", DataTable) if final_check: - logger.warning(f"Table {table_id} still exists! Forcing removal...") + logger.error( + f"CRITICAL: Table {table_id} still exists after {max_wait_attempts} attempts!" + ) final_check.remove() - content.refresh() + content.refresh(layout=True) + import time + + time.sleep(1.0) # Wait a full second + + # Check one more time + try: + still_there = content.query_one(f"#{table_id}", DataTable) + if still_there: + # This should never happen - log and skip mounting + logger.error( + f"FATAL: Cannot remove {table_id} even after 1 second wait. Skipping mount to prevent DuplicateIds." + ) + self.app.notify( + "Table refresh failed. Please try again.", severity="error" + ) + return + except Exception: + # Good - finally gone + logger.info(f"Table {table_id} finally removed after extended wait") + pass except Exception: + # Good - table doesn't exist pass content.mount(review_table) logger.debug(f"Successfully mounted {table_id}") + # CRITICAL: Prevent default first-row selection + # Textual auto-focuses new DataTables, causing first row to be selected + # Solution: temporarily disable focus, then re-enable + try: + review_table.can_focus = False + # Schedule re-enabling focus after UI settles + self.set_timer(0.1, lambda: setattr(review_table, "can_focus", True)) + logger.debug("Disabled initial table focus to prevent default selection") + except Exception as e: + logger.debug(f"Could not prevent default focus: {e}") + # Row count display only (removed Select All, Clear, Delete buttons) if table_type != "remaining": # Check if controls container already exists, reuse if it does @@ -2012,6 +2346,90 @@ class PolicyPrepWorkflowScreen(Screen): f"Deleted {len(indices_to_delete)} items", severity="information" ) + def _copy_selected_path_rows(self) -> None: + """Copy selected path/publisher rows to clipboard as tab-separated values.""" + if not hasattr(self, "selected_path_rows") or not self.selected_path_rows: + self.app.notify("No rows selected to copy", severity="warning") + return + + # Determine which dataframe to use + df = None + if self.current_path_review_type == "paths": + # Combine primary and secondary paths + dfs = [] + if self.primary_paths_df is not None and not self.primary_paths_df.empty: + df_copy = self.primary_paths_df.copy() + df_copy["type"] = "primary" + dfs.append(df_copy) + if ( + self.secondary_paths_df is not None + and not self.secondary_paths_df.empty + ): + df_copy = self.secondary_paths_df.copy() + df_copy["type"] = "secondary" + dfs.append(df_copy) + + if dfs: + df = pd.concat(dfs, ignore_index=True) + elif self.current_path_review_type == "publishers": + df = self.publishers_df + else: + df = self.remaining_hashes_df + + if df is None or df.empty: + self.app.notify("No data to copy", severity="warning") + return + + # Get selected rows + indices_to_copy = [int(idx) for idx in self.selected_path_rows] + selected_df = df.loc[df.index.isin(indices_to_copy)] + + if selected_df.empty: + self.app.notify("No valid rows to copy", severity="warning") + return + + # Convert to dictionary format (list of dicts) + try: + # Convert DataFrame to list of dictionaries + rows_as_dicts = selected_df.to_dict("records") + + # Format as Python dictionary representation + import json + + dict_data = json.dumps(rows_as_dicts, indent=2) + + # Copy to clipboard + import platform + import subprocess + + if platform.system() == "Windows": + # Windows clipboard + subprocess.run(["clip"], input=dict_data.encode("utf-8"), check=True) + elif platform.system() == "Darwin": + # macOS clipboard + subprocess.run(["pbcopy"], input=dict_data.encode("utf-8"), check=True) + else: + # Linux clipboard (try xclip first, then xsel) + try: + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=dict_data.encode("utf-8"), + check=True, + ) + except FileNotFoundError: + subprocess.run( + ["xsel", "--clipboard", "--input"], + input=dict_data.encode("utf-8"), + check=True, + ) + + self.app.notify( + f"Copied {len(selected_df)} rows as JSON", severity="information" + ) + except Exception as e: + logger.error(f"Failed to copy to clipboard: {e}") + self.app.notify(f"Failed to copy: {str(e)}", severity="error") + def _build_preflight(self) -> None: """Build preflight files for testing.""" try: @@ -2028,10 +2446,20 @@ class PolicyPrepWorkflowScreen(Screen): content = self.query_one("#content_area", Vertical) content.remove_children() - summary = Static( - "Test Mode - Preview Changes\n\n" - "Review the paths that will be added to your policy:" - ) + # Check what we're actually applying + has_paths = ( + self.primary_paths_df is not None and not self.primary_paths_df.empty + ) or (self.secondary_paths_df is not None and not self.secondary_paths_df.empty) + has_publishers = self.publishers_df is not None and not self.publishers_df.empty + has_hashes = self.approved_df is not None and not self.approved_df.empty + + # Dynamic summary based on what we have + if has_paths or has_publishers: + summary_text = "Test Mode - Preview Changes\n\nReview the paths that will be added to your policy:" + else: + summary_text = "Test Mode - Preview Changes\n\nReview the hash approvals that will be added to your allowlist:" + + summary = Static(summary_text) summary.styles.margin = (1, 1) summary.styles.text_style = "bold" content.mount(summary) @@ -2083,10 +2511,22 @@ class PolicyPrepWorkflowScreen(Screen): # Show hash count if self.approved_df is not None and not self.approved_df.empty: - hash_info = Static( - f"\nπŸ” Individual Hash Approvals: {len(self.approved_df):,} hashes\n" - f" (Files not covered by paths or publishers)" + # Check if hashes are the only thing being applied + has_other_rules = ( + (self.primary_paths_df is not None and not self.primary_paths_df.empty) + or ( + self.secondary_paths_df is not None + and not self.secondary_paths_df.empty + ) + or (self.publishers_df is not None and not self.publishers_df.empty) ) + + if has_other_rules: + hash_text = f"\nπŸ” Individual Hash Approvals: {len(self.approved_df):,} hashes\n (Files not covered by paths or publishers)" + else: + hash_text = f"\nπŸ” Individual Hash Approvals: {len(self.approved_df):,} hashes\n (All approved files will be added by hash)" + + hash_info = Static(hash_text) hash_info.styles.margin = (1, 1) content.mount(hash_info) @@ -2106,7 +2546,7 @@ class PolicyPrepWorkflowScreen(Screen): content.mount(button_container) back_btn = Button( - "← Back to Review", id="back_to_path_review", variant="default" + "Ò† Back to Review", id="back_to_path_review", variant="default" ) liftoff_btn = Button( "Liftoff - Apply Changes πŸš€", id="liftoff", variant="success" @@ -2365,7 +2805,7 @@ class PolicyPrepWorkflowScreen(Screen): try: table = self.query_one(f"#{table_id}", DataTable) - # Update checkboxes in place without rebuilding the table + # Update checkboxes and apply styling to all cells in selected rows row_index = 0 for row_key in table.rows.keys(): # Get the actual value from the RowKey object @@ -2382,8 +2822,55 @@ class PolicyPrepWorkflowScreen(Screen): except Exception as e: logger.error(f"Could not update cell at row {row_index}: {e}") + # CRITICAL: Apply visual styling to ALL cells in the row if selected + # This creates the visual "highlight" effect for multi-select + try: + if is_selected: + # Get the number of columns + num_cols = len(table.columns) + # Update each cell with Rich styling for background color + for col_idx in range(num_cols): + try: + # Get current cell value + current_value = str( + table.get_cell_at((row_index, col_idx)) + ) + # Wrap in Rich markup for background color + # Using reverse video to invert colors + styled_value = f"[reverse]{current_value}[/reverse]" + table.update_cell_at((row_index, col_idx), styled_value) + except Exception as cell_err: + logger.debug( + f"Could not style cell ({row_index}, {col_idx}): {cell_err}" + ) + else: + # Remove styling from deselected rows + num_cols = len(table.columns) + for col_idx in range(num_cols): + try: + current_value = str( + table.get_cell_at((row_index, col_idx)) + ) + # Remove Rich markup if present + if current_value.startswith("[reverse]"): + clean_value = current_value.replace( + "[reverse]", "" + ).replace("[/reverse]", "") + table.update_cell_at( + (row_index, col_idx), clean_value + ) + except Exception as cell_err: + logger.debug( + f"Could not unstyle cell ({row_index}, {col_idx}): {cell_err}" + ) + except Exception as style_err: + logger.debug(f"Error styling row {row_index}: {style_err}") + row_index += 1 + # Refresh table to show changes + table.refresh() + except Exception as e: logger.error(f"Error refreshing table {table_id}: {e}", exc_info=True) @@ -2655,21 +3142,66 @@ class PolicyPrepWorkflowScreen(Screen): else: self._show_destination_policy_selection() - # Tab switching buttons + # Tab switching buttons - disable immediately to prevent double-clicks elif button_id == "show_approved_tab": - self._show_review_table("approved") + # Prevent rapid clicking - check if table creation is already in progress + if self._creating_review_table: + logger.debug("Ignoring tab click - table creation already in progress") + return + # Disable this button immediately + event.button.disabled = True + try: + self._show_review_table("approved") + finally: + event.button.disabled = False elif button_id == "show_needs_review_tab": - self._show_review_table("needs_review") + # Prevent rapid clicking - check if table creation is already in progress + if self._creating_review_table: + logger.debug("Ignoring tab click - table creation already in progress") + return + # Disable this button immediately + event.button.disabled = True + try: + self._show_review_table("needs_review") + finally: + event.button.disabled = False elif button_id == "show_paths_tab": - self._show_path_review_table("paths") + # Prevent rapid clicking - check if table creation is already in progress + if self._creating_path_table: + logger.debug("Ignoring tab click - table creation already in progress") + return + # Disable this button immediately + event.button.disabled = True + try: + self._show_path_review_table("paths") + finally: + event.button.disabled = False elif button_id == "show_publishers_tab": - self._show_path_review_table("publishers") + # Prevent rapid clicking - check if table creation is already in progress + if self._creating_path_table: + logger.debug("Ignoring tab click - table creation already in progress") + return + # Disable this button immediately + event.button.disabled = True + try: + self._show_path_review_table("publishers") + finally: + event.button.disabled = False elif button_id == "show_remaining_tab": - self._show_path_review_table("remaining") + # Prevent rapid clicking - check if table creation is already in progress + if self._creating_path_table: + logger.debug("Ignoring tab click - table creation already in progress") + return + # Disable this button immediately + event.button.disabled = True + try: + self._show_path_review_table("remaining") + finally: + event.button.disabled = False # Row selection buttons elif button_id == "select_all_rows": @@ -2756,6 +3288,9 @@ class PolicyPrepWorkflowScreen(Screen): ) else: # Save the reviewed data before continuing + logger.info( + f"Saving reviewed data: approved={len(self.approved_df) if self.approved_df is not None else 0}, needs_review={len(self.needs_review_df) if self.needs_review_df is not None else 0}" + ) self._save_reviewed_data() # Show loading screen then build paths self._show_path_building_screen() @@ -2770,14 +3305,31 @@ class PolicyPrepWorkflowScreen(Screen): ) return - # Validate that path review is complete - if (self.primary_paths_df is None or self.primary_paths_df.empty) and ( - self.publishers_df is None or self.publishers_df.empty - ): + # Validate that we have SOMETHING to build with (paths, publishers, or hashes) + has_paths = ( + self.primary_paths_df is not None and not self.primary_paths_df.empty + ) or ( + self.secondary_paths_df is not None + and not self.secondary_paths_df.empty + ) + has_publishers = ( + self.publishers_df is not None and not self.publishers_df.empty + ) + has_hashes = self.approved_df is not None and not self.approved_df.empty + + if not has_paths and not has_publishers and not has_hashes: self.app.notify( - "No paths or publishers to build preflight with!", severity="error" + "No paths, publishers, or hashes to build preflight with!", + severity="error", ) else: + # Build with whatever we have + if not has_paths and not has_publishers and has_hashes: + self.app.notify( + f"Building preflight with {len(self.approved_df)} hash approvals only.", + severity="information", + timeout=3, + ) self._build_preflight() elif button_id == "back_to_path_review": @@ -3018,7 +3570,7 @@ class PolicyPrepWorkflowScreen(Screen): "select_destination": self._show_source_policy_selection, "select_allowlist": self._show_destination_policy_selection, "fetch_data": self._show_allowlist_selection, - "first_review": self._show_fetch_data, + "first_review": lambda: self._show_fetch_data_after_clearing_paths(), "second_review": self._show_fetch_results, "test": self._show_path_results, "complete": lambda: self.app.pop_screen(), @@ -3046,6 +3598,13 @@ class PolicyPrepWorkflowScreen(Screen): elif self.workflow_stage == "second_review": self._delete_selected_path_rows() + def action_copy_rows(self) -> None: + """Copy selected rows to clipboard.""" + if self.workflow_stage == "first_review": + self._copy_selected_rows() + elif self.workflow_stage == "second_review": + self._copy_selected_path_rows() + def action_select_all(self) -> None: """Select all rows in the current table.""" if self.workflow_stage == "first_review": diff --git a/TUI/Widgets/multiagentselector.py b/TUI/Widgets/multiagentselector.py index bc77706..b3603f3 100644 --- a/TUI/Widgets/multiagentselector.py +++ b/TUI/Widgets/multiagentselector.py @@ -14,6 +14,7 @@ # along with this program. If not, see . import difflib +from pathlib import Path import re from typing import List, Optional @@ -57,7 +58,7 @@ class MultiAgentSelector(Widget): def compose(self): yield Header(show_clock=True, icon="βš™") - title_text = Static("πŸ–₯️ Agent Selector", id="selector_title") + title_text = Static("πŸ–₯️ Agent Selector", id="selector_title") title_text.styles.margin = (0, 0, 0, 1) yield title_text @@ -77,7 +78,7 @@ class MultiAgentSelector(Widget): text_area.styles.overflow_y = "auto" yield text_area - with Horizontal(id="switch_search_container"): + with Horizontal(id="switch_container"): switch = Switch(value=False, id="match_switch") switch.styles.width = "auto" switch.styles.margin = (1, 0, 0, 0) @@ -89,8 +90,13 @@ class MultiAgentSelector(Widget): switch_label.styles.margin = (2, 1, 0, 0) yield switch_label + with Horizontal(id="action_buttons_container"): + load_file = Button("πŸ“‚ Load File", id="load_file_button") + load_file.styles.margin = (1, 1, 0, 1) + yield load_file + search = Button("πŸ” Search", id="search_button") - search.styles.margin = (1, 0, 0, 0) + search.styles.margin = (1, 0, 0, 1) yield search with Horizontal() as select_buttons: @@ -152,6 +158,9 @@ class MultiAgentSelector(Widget): ] self.post_message(self.AgentsSelected(selected_agents)) event.stop() + elif btn_id == "load_file_button": + self._load_from_file() + event.stop() elif btn_id == "search_button": self.update_matches() event.stop() @@ -166,7 +175,7 @@ class MultiAgentSelector(Widget): match_list.add_option((name, name)) unmatched_label = self.query_one("#unmatched_label", Static) if unmatched: - unmatched_label.update(f"⚠️ No matches for: {', '.join(unmatched)}") + unmatched_label.update(f"Òő ï¸ No matches for: {', '.join(unmatched)}") else: unmatched_label.update("") @@ -214,3 +223,118 @@ class MultiAgentSelector(Widget): else: unmatched.append(name) return sorted(matched), unmatched + + def _load_from_file(self): + """Safely load device names from a text file.""" + try: + # Import here to avoid issues if tkinter isn't available + import tkinter as tk + from tkinter import filedialog + + # Create file dialog + root = tk.Tk() + root.withdraw() + + file_path = filedialog.askopenfilename( + title="Select device list file", + filetypes=[ + ("Text files", "*.txt"), + ("CSV files", "*.csv"), + ("All files", "*.*"), + ], + ) + + if not file_path: + # User cancelled + return + + # Validate file path + path_obj = Path(file_path) + if not path_obj.exists(): + self.app.notify("File does not exist", severity="error", timeout=3) + return + + if not path_obj.is_file(): + self.app.notify( + "Selected path is not a file", severity="error", timeout=3 + ) + return + + # Check file size (limit to 1 MB for safety) + file_size = path_obj.stat().st_size + if file_size > 1_000_000: # 1 MB + self.app.notify( + f"File too large ({file_size:,} bytes). Maximum 1 MB.", + severity="error", + timeout=5, + ) + return + + # Read file with proper encoding to preserve emojis + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + except UnicodeDecodeError: + # Try with different encoding if UTF-8 fails + try: + with open(file_path, "r", encoding="latin-1") as f: + content = f.read() + self.app.notify( + "File loaded with Latin-1 encoding (UTF-8 failed)", + severity="warning", + timeout=3, + ) + except Exception as e: + self.app.notify( + f"Error reading file: {str(e)}", severity="error", timeout=5 + ) + return + + # Validate and sanitize content + lines = content.split("\n") + valid_lines = [] + invalid_count = 0 + + # Pattern for valid hostnames/device names + # Allows: letters, numbers, hyphens, underscores, periods, and Unicode chars + hostname_pattern = re.compile(r"^[\w\-\.\u0080-\uFFFF]+$", re.UNICODE) + + for line in lines: + line = line.strip() + if not line: + continue # Skip empty lines + + # Check if line looks like a valid hostname/device name + if hostname_pattern.match(line): + valid_lines.append(line) + else: + invalid_count += 1 + # Log but don't add invalid entries + + if not valid_lines: + self.app.notify( + "No valid device names found in file", severity="warning", timeout=3 + ) + return + + # Update text area with validated content + text_area = self.query_one("#device_input", TextArea) + text_area.text = "\n".join(valid_lines) + + # Show notification + msg = f"βœ… Loaded {len(valid_lines)} devices from file" + if invalid_count > 0: + msg += f" ({invalid_count} invalid entries skipped)" + + self.app.notify(msg, severity="information", timeout=5) + + except ImportError: + self.app.notify( + "tkinter not available - cannot open file dialog", + severity="error", + timeout=3, + ) + except Exception as e: + self.app.notify( + f"Error loading file: {str(e)}", severity="error", timeout=5 + ) diff --git a/TUI/Widgets/serverlogwidget.py b/TUI/Widgets/serverlogwidget.py index 79ef132..0402700 100644 --- a/TUI/Widgets/serverlogwidget.py +++ b/TUI/Widgets/serverlogwidget.py @@ -19,7 +19,7 @@ import logging from bson import ObjectId from textual.app import ComposeResult from textual.containers import Container, Vertical -from textual.widgets import Button, DataTable, Static +from textual.widgets import Button, DataTable, Input, Static from services.API import AirlockAPIWrapper @@ -67,6 +67,19 @@ class ServerLogWidget(Vertical): height: auto; layout: horizontal; padding: 1; + align: left middle; + } + + ServerLogWidget .filter_label { + width: auto; + height: 3; + content-align: left middle; + padding-right: 1; + } + + ServerLogWidget #filter_input { + width: 40; + margin-right: 1; } ServerLogWidget Button { @@ -77,11 +90,15 @@ class ServerLogWidget(Vertical): def __init__(self, api: AirlockAPIWrapper): super().__init__() self.api = api + self.all_logs = [] # Store all logs for filtering + self.columns = [] # Store column names def compose(self) -> ComposeResult: yield Static("Loading server logs (last 72 hours)...", id="status_bar") yield DataTable(id="server_log_table") with Container(id="button_container"): + yield Static("Filter:", classes="filter_label") + yield Input(placeholder="Filter (use * and ? wildcards)", id="filter_input") yield Button("Refresh", id="refresh_button", variant="primary") def on_mount(self) -> None: @@ -105,23 +122,28 @@ class ServerLogWidget(Vertical): if not logs: status.update("β„ΉΓ―ΒΈ No server logs found in the last 72 hours.") table.clear(columns=True) + self.all_logs = [] + self.columns = [] return + # Store all logs for filtering + self.all_logs = logs + # Clear existing data table.clear(columns=True) # Add columns based on the first log entry if logs: first_log = logs[0] - columns = [col for col in first_log.keys() if col != "checkpoint"] + self.columns = [col for col in first_log.keys() if col != "checkpoint"] - for col in columns: + for col in self.columns: table.add_column(col, key=col) # Add rows in reverse order so newest entries are at the top for log_entry in reversed(logs): row_data = [] - for col in columns: + for col in self.columns: value = log_entry.get(col, "") # Format datetime column to be more readable if col == "datetime" and value: @@ -143,12 +165,86 @@ class ServerLogWidget(Vertical): logger.info(f"Loaded {len(logs)} server log entries") else: status.update("β„ΉΓ―ΒΈ No log entries found.") + self.all_logs = [] + self.columns = [] except Exception as exc: error_msg = f"❌ Error loading server logs: {exc}" status.update(error_msg) logger.error(f"Failed to load server logs: {exc}", exc_info=True) table.clear(columns=True) + self.all_logs = [] + self.columns = [] + + def filter_logs(self, filter_text: str) -> None: + """Filter the logs based on the filter text with wildcard support.""" + import fnmatch + + table = self.query_one("#server_log_table", DataTable) + status = self.query_one("#status_bar", Static) + + if not self.all_logs: + return + + # Clear existing data + table.clear(columns=True) + + # Re-add columns + for col in self.columns: + table.add_column(col, key=col) + + # Filter logs + filtered_logs = [] + if filter_text.strip(): + filter_pattern = filter_text.strip().lower() + for log_entry in self.all_logs: + # Check if any field matches the filter pattern + match = False + for col in self.columns: + value = str(log_entry.get(col, "")).lower() + if fnmatch.fnmatch(value, filter_pattern): + match = True + break + if match: + filtered_logs.append(log_entry) + else: + # No filter, show all logs + filtered_logs = self.all_logs + + # Add filtered rows in reverse order + for log_entry in reversed(filtered_logs): + row_data = [] + for col in self.columns: + value = log_entry.get(col, "") + # Format datetime column to be more readable + if col == "datetime" and value: + try: + dt = datetime.datetime.fromisoformat( + str(value).replace("Z", "+00:00") + ) + value = dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + pass + row_data.append(str(value)) + table.add_row(*row_data) + + if filter_text.strip(): + status.update( + f"βœ… Showing {len(filtered_logs)} of {len(self.all_logs)} log entries (filtered)" + ) + else: + status.update( + f"βœ… Loaded {len(self.all_logs)} log entries from the last 72 hours" + ) + + logger.info( + f"Filtered to {len(filtered_logs)} entries with pattern: {filter_text}" + ) + + def on_input_changed(self, event: Input.Changed) -> None: + """Handle filter input changes.""" + if event.input.id == "filter_input": + self.filter_logs(event.value) def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" @@ -156,4 +252,10 @@ class ServerLogWidget(Vertical): if button_id == "refresh_button": self.load_logs() + # Clear the filter input when refreshing + try: + filter_input = self.query_one("#filter_input", Input) + filter_input.value = "" + except Exception: + pass event.stop() -- 2.34.1