diff --git a/.gitignore b/.gitignore index 48ad36c..789509d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ database/ +logs/ # Generated by Cargo # will have compiled files and executables diff --git a/unshell-gui/src/app/mod.rs b/unshell-gui/src/app/mod.rs index 8f729b1..91037b1 100644 --- a/unshell-gui/src/app/mod.rs +++ b/unshell-gui/src/app/mod.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use crate::{ app::windows::WindowWrapper, auth::Auth, config::Config, flowchart::FlowChart, - payload_config::PayloadConfig, + log_viewer::LogViewer, payload_config::PayloadConfig, }; pub use app::TemplateApp; use egui_tiles::{TileId, Tree}; @@ -19,6 +19,7 @@ pub struct AppState { pub flowchart: FlowChart, pub config: Config, pub payload_config: PayloadConfig, + pub log_viewer: LogViewer, } impl AppState { @@ -27,6 +28,7 @@ impl AppState { (AppWindow::Flowchart, "Flowchart"), (AppWindow::PayloadConfig, "Payload Config"), (AppWindow::Config, "Config"), + (AppWindow::LogViewer, "Log Viewer"), ]) .iter() .enumerate() @@ -100,6 +102,7 @@ pub enum AppWindow { Flowchart, Config, PayloadConfig, + LogViewer, } impl AppWindow { @@ -108,6 +111,7 @@ impl AppWindow { AppWindow::Flowchart => state.flowchart.paint(ui), AppWindow::Config => state.config.update(&mut state.auth, ui), AppWindow::PayloadConfig => state.payload_config.update(ui), + AppWindow::LogViewer => state.log_viewer.update(&mut state.auth, ui), } } diff --git a/unshell-gui/src/auth/mod.rs b/unshell-gui/src/auth/mod.rs index fe19aa0..a4b59af 100644 --- a/unshell-gui/src/auth/mod.rs +++ b/unshell-gui/src/auth/mod.rs @@ -162,7 +162,6 @@ impl Auth { format!("Bearer {}", token.token), Closure::once_into_js(move |ok: bool, response: String| { if ok { - crate::log(&response); if let Ok(value) = serde_json::from_str::(&response) { ret(value) } else { diff --git a/unshell-gui/src/lib.rs b/unshell-gui/src/lib.rs index 1d6a030..80ce4d7 100644 --- a/unshell-gui/src/lib.rs +++ b/unshell-gui/src/lib.rs @@ -7,6 +7,7 @@ pub mod app; mod auth; mod config; mod flowchart; +mod log_viewer; mod payload_config; use std::time::Duration; diff --git a/unshell-gui/src/log_viewer/mod.rs b/unshell-gui/src/log_viewer/mod.rs new file mode 100644 index 0000000..8b264dc --- /dev/null +++ b/unshell-gui/src/log_viewer/mod.rs @@ -0,0 +1,43 @@ +use crate::auth::Auth; + +use std::sync::Arc; +use std::sync::Mutex; + +#[derive(serde::Deserialize, serde::Serialize)] +pub struct LogViewer { + #[serde(skip)] + state: Arc>, +} + +#[derive(Default)] +struct LogState { + logs: Vec, + // trees: Option>, + // tree_keys: Option>, + // is_requesting: bool, +} + +impl LogViewer { + pub fn update(&mut self, auth: &mut Auth, ui: &mut egui::Ui) { + ui.heading("Log Viewer"); + for log in &self.state.lock().unwrap().logs { + ui.label(log); + } + if ui.button("Poll").clicked() { + let state_clone = self.state.clone(); + auth.get(&format!("/api/log/{}", 0), move |e: Vec| { + (*state_clone.lock().unwrap()).logs = e; + // crate::log(&format!("{e:?}")); + }); + } + } +} + +impl Default for LogViewer { + fn default() -> Self { + Self { + // logs: Vec::new(), + state: Arc::new(Mutex::new(LogState::default())), + } + } +} diff --git a/unshell-lib/src/logger/pretty_logger.rs b/unshell-lib/src/logger/pretty_logger.rs index 86e7272..68ade74 100644 --- a/unshell-lib/src/logger/pretty_logger.rs +++ b/unshell-lib/src/logger/pretty_logger.rs @@ -2,7 +2,9 @@ use chrono::{DateTime, Utc}; use crate::logger::{LogLevel, Logger, Record}; -pub struct PrettyLogger; +pub struct PrettyLogger { + output: Option>, +} // static TRACE_COLOR: &str = "\x1b[34m"; static DEBUG_COLOR: &str = "\x1b[36m"; @@ -17,6 +19,10 @@ static GREY: &str = "\x1b[90m"; impl Logger for PrettyLogger { fn log(&self, message: Record) { + if let Some(ref func) = self.output { + (*func)(&message) + } + let log_level = match message.log_level { LogLevel::Debug => format!("{DEBUG_COLOR}DBUG"), LogLevel::Info => format!("{INFO_COLOR}INFO"), @@ -38,6 +44,15 @@ impl Logger for PrettyLogger { impl PrettyLogger { pub fn init() { - crate::logger::set_logger_box(Box::new(PrettyLogger)); + crate::logger::set_logger_box(Box::new(PrettyLogger { output: None })); + } + + pub fn init_output(output: T) + where + T: Fn(&Record) + 'static, + { + crate::logger::set_logger_box(Box::new(PrettyLogger { + output: Some(Box::new(output)), + })); } } diff --git a/unshell-server/src/api/app.rs b/unshell-server/src/api/app.rs index 5c6c690..8c245b9 100644 --- a/unshell-server/src/api/app.rs +++ b/unshell-server/src/api/app.rs @@ -9,6 +9,7 @@ use unshell_lib::{debug, info}; use crate::{ api::{auth, structs::CurrentUser}, + logger::Logger, server::Server, }; @@ -25,11 +26,31 @@ pub async fn start_api(address: &str, server: Server) { router = route_get_tree_keys(router); router = route_trees(router); + router = route_get_log(router); + axum::serve(listener, router.with_state(server)) .await .expect("Error serving application"); } +// Route the "keys" api for each tree +fn route_get_log(router: Router) -> Router { + router.route( + "/api/log/{offset}", + get( + async |State(_): State, + Extension(_): Extension, + Path(offset): Path| { + debug!("GET /api/log/{}", offset); + let result = Logger::poll_logs(offset); + + Json(serde_json::to_value(result).unwrap()) + }, + ) + .layer(middleware::from_fn(auth::authorize)), + ) +} + // Route the "keys" api for each tree fn route_get_trees(router: Router) -> Router { router.route( diff --git a/unshell-server/src/lib.rs b/unshell-server/src/lib.rs index 263369d..0c40c0a 100644 --- a/unshell-server/src/lib.rs +++ b/unshell-server/src/lib.rs @@ -1,6 +1,7 @@ // #![macro_use] mod api; +pub mod logger; mod server; pub use api::app::start_api; diff --git a/unshell-server/src/logger.rs b/unshell-server/src/logger.rs new file mode 100644 index 0000000..e2d063a --- /dev/null +++ b/unshell-server/src/logger.rs @@ -0,0 +1,182 @@ +use chrono::Local; +// use lazy_static::lazy_static; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; + +// --- Constants --- +/// The directory where log files will be stored. +const LOG_DIR: &str = "logs"; + +/// The maximum number of logs to return in one call to `poll_logs`. +const LOG_COUNT: usize = 10; + +/// The full path to the log file. +/// Initialized once based on the startup time. +#[static_init::dynamic] +static LOG_FILE_PATH: PathBuf = { + // 1. Determine the log directory path + let log_dir_path = PathBuf::from(LOG_DIR); + + // 2. Create the log directory if it does not exist + if let Err(e) = fs::create_dir_all(&log_dir_path) { + eprintln!("Error creating log directory {:?}: {}", log_dir_path, e); + // Panic or handle error as appropriate for your application's needs + // Panicking here to ensure the logger can't be used if the path is invalid/unwritable + panic!("Failed to initialize log directory."); + } + + // 3. Generate the unique filename based on the current local time + let now = Local::now(); + let filename = format!("{}.log", now.format("%Y%m%d_%H%M%S")); + + // 4. Combine the directory path and the filename + log_dir_path.join(filename) +}; + +/// A static utility module for logging operations. +pub struct Logger; + +impl Logger { + /// Writes a log entry to the current log file. + /// + /// The log entry includes a timestamp and the provided message. + /// + /// # Arguments + /// + /// * `message` - The string content of the log entry. + pub fn log(message: String) { + // 1. Format the complete log line with timestamp + let now = Local::now(); + let log_line = format!("[{}] {}\n", now.format("%Y-%m-%d %H:%M:%S%.3f"), message); + + // 2. Open the file in append mode, creating it if it doesn't exist. + // The file path is guaranteed to be valid due to the `lazy_static` initialization. + match OpenOptions::new() + .create(true) + .append(true) + .open(&*LOG_FILE_PATH) + { + Ok(mut file) => { + // 3. Write the log line to the file + if let Err(e) = file.write_all(log_line.as_bytes()) { + eprintln!("Error writing log to file {:?}: {}", *LOG_FILE_PATH, e); + } + } + Err(e) => { + eprintln!("Error opening log file {:?}: {}", *LOG_FILE_PATH, e); + } + } + } + + /// Reads and returns the most recent logs from the file. + /// + /// The total number of logs returned is limited by `LOG_COUNT`. + /// The `offset` determines how far back in history to start reading. + /// + /// # Arguments + /// + /// * `offset` - The number of most recent logs to skip. + /// + /// # Returns + /// + /// A `Vec` containing the logs, or an empty `Vec` on failure. + pub fn poll_logs(offset: usize) -> Vec { + // Array of String is not a idiomatic return type in Rust, + // so we return a Vector, which serves the same purpose. + // The size constraint (LOG_COUNT) is applied internally. + match File::open(&*LOG_FILE_PATH) { + Ok(file) => { + let reader = BufReader::new(file); + + // Collect all lines into a vector + let lines: Vec = reader + .lines() + .filter_map(|line| line.ok()) // Ignore lines that fail to read + .collect(); + + // Determine the starting index for the slice (from the end of the vector) + // This logic correctly handles the offset and LOG_COUNT limits. + + let total_lines = lines.len(); + if offset >= total_lines { + // Offset is past the beginning of the file, return nothing + return Vec::new(); + } + + // Start from the end, minus the offset, minus the number of logs to read. + // We use checked subtraction to prevent panic if it would result in a negative number. + let start_index = total_lines + .checked_sub(offset) + .and_then(|i| i.checked_sub(LOG_COUNT)) + .unwrap_or(0); // If either subtraction fails, start from 0 + + // End index is determined by subtracting the offset from the total length. + let end_index = total_lines.checked_sub(offset).unwrap_or(total_lines); + + // Get the slice of the lines + let slice = &lines[start_index..end_index]; + + // The logs are currently in historical order (oldest to newest within the slice). + // We must reverse them to return the "most recent" logs in descending order. + slice.iter().rev().cloned().collect() + } + Err(e) => { + // Return an empty vector and print an error message if the file cannot be read + eprintln!("Error reading log file {:?}: {}", *LOG_FILE_PATH, e); + Vec::new() + } + } + } +} + +// --- Example Usage --- + +// fn main() { +// // Write some logs +// Logger::log("Application started.".to_string()); +// Logger::log("Configuration loaded.".to_string()); + +// for i in 1..=20 { +// Logger::log(format!("Processing request #{}", i)); +// } + +// Logger::log("Task completed.".to_string()); + +// // --- Test 1: Get the 10 most recent logs (offset 0) --- +// println!("--- Most Recent Logs (LOG_COUNT={}) ---", LOG_COUNT); +// let recent_logs = Logger::poll_logs(0); +// for log in &recent_logs { +// println!("{}", log); +// } + +// // The output should be: +// // [timestamp] Task completed. +// // [timestamp] Processing request #20 +// // [timestamp] Processing request #19 +// // ... +// // [timestamp] Processing request #12 + +// println!( +// "\n--- Logs from the past (Offset 15, LOG_COUNT={}) ---", +// LOG_COUNT +// ); +// // --- Test 2: Skip the 15 most recent logs, then get the next 10 --- +// let historical_logs = Logger::poll_logs(15); +// for log in &historical_logs { +// println!("{}", log); +// } + +// // The output should be: +// // [timestamp] Processing request #6 +// // [timestamp] Processing request #5 +// // ... +// // [timestamp] Application started. + +// println!("\n--- Testing a high offset (Offset 100) ---"); +// // --- Test 3: Test an offset that goes beyond the log file length --- +// let empty_logs = Logger::poll_logs(100); +// if empty_logs.is_empty() { +// println!("Correctly returned empty set for high offset."); +// } +// } diff --git a/unshell-server/src/main.rs b/unshell-server/src/main.rs index 56d785b..97c8133 100644 --- a/unshell-server/src/main.rs +++ b/unshell-server/src/main.rs @@ -25,7 +25,9 @@ pub struct Args { async fn main() { let args = Args::parse(); - unshell_lib::logger::PrettyLogger::init(); + unshell_lib::logger::PrettyLogger::init_output(|message| { + unshell_server::logger::Logger::log(format!("{message:?}")); + }); let database = Server::new(args.database_name);