From 5a864938248a1c7ccf58c16a59d3e15c5883b050 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Mon, 28 Jul 2025 23:36:51 +0100 Subject: [PATCH] feat: add daemon log viewer to web UI Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- Cargo.toml | 1 - src/api.rs | 20 +++++++++++++++--- src/config.rs | 4 ++-- src/logger.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 31 +++++++++++++++------------- static/index.html | 6 ++++++ static/script.js | 17 ++++++++++++++++ 7 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 src/logger.rs diff --git a/Cargo.toml b/Cargo.toml index 3e04d14..2321b82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ actix-files = "0.6" tokio = { version = "1", features = ["full"] } clap = { version = "4.4", features = ["derive"] } log = "0.4" -env_logger = "0.11" daemonize = "0.5.0" diff --git a/src/api.rs b/src/api.rs index 7b84c3f..bdb1278 100644 --- a/src/api.rs +++ b/src/api.rs @@ -5,6 +5,7 @@ use chrono::{Local, Timelike}; use get_if_addrs::get_if_addrs; use serde::{Deserialize, Serialize}; use serde_json; +use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use crate::config::{self, Config}; @@ -33,6 +34,7 @@ struct ApiStatus { pub struct AppState { pub ltc_state: Arc>, pub config: Arc>, + pub log_buffer: Arc>>, } #[get("/api/status")] @@ -117,6 +119,12 @@ async fn get_config(data: web::Data) -> impl Responder { HttpResponse::Ok().json(&*config) } +#[get("/api/logs")] +async fn get_logs(data: web::Data) -> impl Responder { + let logs = data.log_buffer.lock().unwrap(); + HttpResponse::Ok().json(&*logs) +} + #[post("/api/config")] async fn update_config( data: web::Data, @@ -126,23 +134,28 @@ async fn update_config( *config = req.into_inner(); if config::save_config("config.yml", &config).is_ok() { - eprintln!("🔄 Saved config via API: {:?}", *config); + log::info!("🔄 Saved config via API: {:?}", *config); HttpResponse::Ok().json(&*config) } else { - HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.yml" })) + log::error!("Failed to write config.yml"); + HttpResponse::InternalServerError().json( + serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }), + ) } } pub async fn start_api_server( state: Arc>, config: Arc>, + log_buffer: Arc>>, ) -> std::io::Result<()> { let app_state = web::Data::new(AppState { ltc_state: state, config: config, + log_buffer: log_buffer, }); - println!("🚀 Starting API server at http://0.0.0.0:8080"); + log::info!("🚀 Starting API server at http://0.0.0.0:8080"); HttpServer::new(move || { App::new() @@ -151,6 +164,7 @@ pub async fn start_api_server( .service(manual_sync) .service(get_config) .service(update_config) + .service(get_logs) // Serve frontend static files .service(fs::Files::new("/", "static/").index_file("index.html")) }) diff --git a/src/config.rs b/src/config.rs index c7caf15..a287a6b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,7 +46,7 @@ impl Config { return Self::default(); } serde_yaml::from_str(&contents).unwrap_or_else(|e| { - eprintln!("Failed to parse config, using default: {}", e); + log::warn!("Failed to parse config, using default: {}", e); Self::default() }) } @@ -82,7 +82,7 @@ pub fn watch_config(path: &str) -> Arc> { let new_cfg = Config::load(&watch_path_for_cb); let mut cfg = config_for_cb.lock().unwrap(); *cfg = new_cfg; - eprintln!("🔄 Reloaded config.yml: {:?}", *cfg); + log::info!("🔄 Reloaded config.yml: {:?}", *cfg); } } }) diff --git a/src/logger.rs b/src/logger.rs new file mode 100644 index 0000000..33c410e --- /dev/null +++ b/src/logger.rs @@ -0,0 +1,52 @@ +use chrono::Local; +use log::{LevelFilter, Log, Metadata, Record}; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +const MAX_LOG_ENTRIES: usize = 100; + +struct RingBufferLogger { + buffer: Arc>>, +} + +impl Log for RingBufferLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= LevelFilter::Info + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + let msg = format!( + "{} [{}] {}", + Local::now().format("%Y-%m-%d %H:%M:%S"), + record.level(), + record.args() + ); + + // Also print to stderr for console/daemon logging + eprintln!("{}", msg); + + let mut buffer = self.buffer.lock().unwrap(); + if buffer.len() == MAX_LOG_ENTRIES { + buffer.pop_front(); + } + buffer.push_back(msg); + } + } + + fn flush(&self) {} +} + +pub fn setup_logger() -> Arc>> { + let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LOG_ENTRIES))); + let logger = RingBufferLogger { + buffer: buffer.clone(), + }; + + // We use `set_boxed_logger` to install our custom logger. + // The `log` crate will then route all log messages to it. + log::set_boxed_logger(Box::new(logger)).expect("Failed to set logger"); + log::set_max_level(LevelFilter::Info); + + buffer +} diff --git a/src/main.rs b/src/main.rs index 5ee280e..f85d298 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod api; mod config; +mod logger; mod serial_input; mod sync_logic; mod system; @@ -14,7 +15,6 @@ use crate::sync_logic::LtcState; use crate::ui::start_ui; use clap::Parser; use daemonize::Daemonize; -use env_logger; use std::{ fs, @@ -57,16 +57,18 @@ fn ensure_config() { if !p.exists() { fs::write(p, DEFAULT_CONFIG.trim()) .expect("Failed to write default config.yml"); - eprintln!("⚙️ Emitted default config.yml"); + log::info!("⚙️ Emitted default config.yml"); } } #[tokio::main(flavor = "current_thread")] async fn main() { + // This must be called before any logging statements. + let log_buffer = logger::setup_logger(); let args = Args::parse(); if let Some(Command::Daemon) = &args.command { - println!("🚀 Starting daemon..."); + log::info!("🚀 Starting daemon..."); // Create files for stdout and stderr in the current directory let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out"); @@ -81,7 +83,7 @@ async fn main() { match daemonize.start() { Ok(_) => { /* Process is now daemonized */ } Err(e) => { - eprintln!("Error daemonizing: {}", e); + log::error!("Error daemonizing: {}", e); return; // Exit if daemonization fails } } @@ -116,9 +118,9 @@ async fn main() { // 5️⃣ Spawn UI or setup daemon logging if args.command.is_none() { - println!("🔧 Watching config.yml..."); - println!("🚀 Serial thread launched"); - println!("🖥️ UI thread launched"); + log::info!("🔧 Watching config.yml..."); + log::info!("🚀 Serial thread launched"); + log::info!("🖥️ UI thread launched"); let ui_state = ltc_state.clone(); let config_clone = config.clone(); let port = "/dev/ttyACM0".to_string(); @@ -126,10 +128,8 @@ async fn main() { start_ui(ui_state, port, config_clone); }); } else { - // In daemon mode, we initialize env_logger. - // This will log to stdout, and the systemd service will capture it. - // The RUST_LOG env var controls the log level (e.g., RUST_LOG=info). - env_logger::init(); + // In daemon mode, logging is already set up to go to stderr. + // The systemd service will capture it. log::info!("🚀 Starting TimeTurner daemon..."); } @@ -141,9 +141,12 @@ async fn main() { { let api_state = ltc_state.clone(); let config_clone = config.clone(); + let log_buffer_clone = log_buffer.clone(); task::spawn_local(async move { - if let Err(e) = start_api_server(api_state, config_clone).await { - eprintln!("API server error: {}", e); + if let Err(e) = + start_api_server(api_state, config_clone, log_buffer_clone).await + { + log::error!("API server error: {}", e); } }); } @@ -154,7 +157,7 @@ async fn main() { std::future::pending::<()>().await; } else { // In TUI mode, block on the channel. - println!("📡 Main thread entering loop..."); + log::info!("📡 Main thread entering loop..."); let _ = task::spawn_blocking(move || { for _frame in rx { // no-op diff --git a/static/index.html b/static/index.html index 1aa7110..f4999e0 100644 --- a/static/index.html +++ b/static/index.html @@ -66,6 +66,12 @@ + + +
+

Logs

+

+            
diff --git a/static/script.js b/static/script.js index 1a7385c..439d07c 100644 --- a/static/script.js +++ b/static/script.js @@ -12,6 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { jitterStatus: document.getElementById('jitter-status'), deltaHistory: document.getElementById('delta-history'), interfaces: document.getElementById('interfaces'), + logs: document.getElementById('logs'), }; const hwOffsetInput = document.getElementById('hw-offset'); @@ -111,6 +112,20 @@ document.addEventListener('DOMContentLoaded', () => { } } + async function fetchLogs() { + try { + const response = await fetch('/api/logs'); + if (!response.ok) throw new Error('Failed to fetch logs'); + const logs = await response.json(); + statusElements.logs.textContent = logs.join('\n'); + // Auto-scroll to the bottom + statusElements.logs.scrollTop = statusElements.logs.scrollHeight; + } catch (error) { + console.error('Error fetching logs:', error); + statusElements.logs.textContent = 'Error fetching logs.'; + } + } + async function triggerManualSync() { syncMessage.textContent = 'Issuing sync command...'; try { @@ -134,7 +149,9 @@ document.addEventListener('DOMContentLoaded', () => { // Initial data load fetchStatus(); fetchConfig(); + fetchLogs(); // Refresh data every 2 seconds setInterval(fetchStatus, 2000); + setInterval(fetchLogs, 2000); });