mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
feat: add daemon log viewer to web UI
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
This commit is contained in:
parent
b803de93de
commit
5a86493824
7 changed files with 111 additions and 20 deletions
|
|
@ -18,7 +18,6 @@ actix-files = "0.6"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
clap = { version = "4.4", features = ["derive"] }
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
|
||||||
daemonize = "0.5.0"
|
daemonize = "0.5.0"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
20
src/api.rs
20
src/api.rs
|
|
@ -5,6 +5,7 @@ use chrono::{Local, Timelike};
|
||||||
use get_if_addrs::get_if_addrs;
|
use get_if_addrs::get_if_addrs;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::config::{self, Config};
|
use crate::config::{self, Config};
|
||||||
|
|
@ -33,6 +34,7 @@ struct ApiStatus {
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub ltc_state: Arc<Mutex<LtcState>>,
|
pub ltc_state: Arc<Mutex<LtcState>>,
|
||||||
pub config: Arc<Mutex<Config>>,
|
pub config: Arc<Mutex<Config>>,
|
||||||
|
pub log_buffer: Arc<Mutex<VecDeque<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/api/status")]
|
#[get("/api/status")]
|
||||||
|
|
@ -117,6 +119,12 @@ async fn get_config(data: web::Data<AppState>) -> impl Responder {
|
||||||
HttpResponse::Ok().json(&*config)
|
HttpResponse::Ok().json(&*config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/api/logs")]
|
||||||
|
async fn get_logs(data: web::Data<AppState>) -> impl Responder {
|
||||||
|
let logs = data.log_buffer.lock().unwrap();
|
||||||
|
HttpResponse::Ok().json(&*logs)
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/api/config")]
|
#[post("/api/config")]
|
||||||
async fn update_config(
|
async fn update_config(
|
||||||
data: web::Data<AppState>,
|
data: web::Data<AppState>,
|
||||||
|
|
@ -126,23 +134,28 @@ async fn update_config(
|
||||||
*config = req.into_inner();
|
*config = req.into_inner();
|
||||||
|
|
||||||
if config::save_config("config.yml", &config).is_ok() {
|
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)
|
HttpResponse::Ok().json(&*config)
|
||||||
} else {
|
} 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(
|
pub async fn start_api_server(
|
||||||
state: Arc<Mutex<LtcState>>,
|
state: Arc<Mutex<LtcState>>,
|
||||||
config: Arc<Mutex<Config>>,
|
config: Arc<Mutex<Config>>,
|
||||||
|
log_buffer: Arc<Mutex<VecDeque<String>>>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
let app_state = web::Data::new(AppState {
|
let app_state = web::Data::new(AppState {
|
||||||
ltc_state: state,
|
ltc_state: state,
|
||||||
config: config,
|
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 || {
|
HttpServer::new(move || {
|
||||||
App::new()
|
App::new()
|
||||||
|
|
@ -151,6 +164,7 @@ pub async fn start_api_server(
|
||||||
.service(manual_sync)
|
.service(manual_sync)
|
||||||
.service(get_config)
|
.service(get_config)
|
||||||
.service(update_config)
|
.service(update_config)
|
||||||
|
.service(get_logs)
|
||||||
// Serve frontend static files
|
// Serve frontend static files
|
||||||
.service(fs::Files::new("/", "static/").index_file("index.html"))
|
.service(fs::Files::new("/", "static/").index_file("index.html"))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ impl Config {
|
||||||
return Self::default();
|
return Self::default();
|
||||||
}
|
}
|
||||||
serde_yaml::from_str(&contents).unwrap_or_else(|e| {
|
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()
|
Self::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +82,7 @@ pub fn watch_config(path: &str) -> Arc<Mutex<Config>> {
|
||||||
let new_cfg = Config::load(&watch_path_for_cb);
|
let new_cfg = Config::load(&watch_path_for_cb);
|
||||||
let mut cfg = config_for_cb.lock().unwrap();
|
let mut cfg = config_for_cb.lock().unwrap();
|
||||||
*cfg = new_cfg;
|
*cfg = new_cfg;
|
||||||
eprintln!("🔄 Reloaded config.yml: {:?}", *cfg);
|
log::info!("🔄 Reloaded config.yml: {:?}", *cfg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
52
src/logger.rs
Normal file
52
src/logger.rs
Normal file
|
|
@ -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<Mutex<VecDeque<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Mutex<VecDeque<String>>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
31
src/main.rs
31
src/main.rs
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod logger;
|
||||||
mod serial_input;
|
mod serial_input;
|
||||||
mod sync_logic;
|
mod sync_logic;
|
||||||
mod system;
|
mod system;
|
||||||
|
|
@ -14,7 +15,6 @@ use crate::sync_logic::LtcState;
|
||||||
use crate::ui::start_ui;
|
use crate::ui::start_ui;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use daemonize::Daemonize;
|
use daemonize::Daemonize;
|
||||||
use env_logger;
|
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
|
|
@ -57,16 +57,18 @@ fn ensure_config() {
|
||||||
if !p.exists() {
|
if !p.exists() {
|
||||||
fs::write(p, DEFAULT_CONFIG.trim())
|
fs::write(p, DEFAULT_CONFIG.trim())
|
||||||
.expect("Failed to write default config.yml");
|
.expect("Failed to write default config.yml");
|
||||||
eprintln!("⚙️ Emitted default config.yml");
|
log::info!("⚙️ Emitted default config.yml");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
// This must be called before any logging statements.
|
||||||
|
let log_buffer = logger::setup_logger();
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
if let Some(Command::Daemon) = &args.command {
|
if let Some(Command::Daemon) = &args.command {
|
||||||
println!("🚀 Starting daemon...");
|
log::info!("🚀 Starting daemon...");
|
||||||
|
|
||||||
// Create files for stdout and stderr in the current directory
|
// Create files for stdout and stderr in the current directory
|
||||||
let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out");
|
let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out");
|
||||||
|
|
@ -81,7 +83,7 @@ async fn main() {
|
||||||
match daemonize.start() {
|
match daemonize.start() {
|
||||||
Ok(_) => { /* Process is now daemonized */ }
|
Ok(_) => { /* Process is now daemonized */ }
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error daemonizing: {}", e);
|
log::error!("Error daemonizing: {}", e);
|
||||||
return; // Exit if daemonization fails
|
return; // Exit if daemonization fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,9 +118,9 @@ async fn main() {
|
||||||
|
|
||||||
// 5️⃣ Spawn UI or setup daemon logging
|
// 5️⃣ Spawn UI or setup daemon logging
|
||||||
if args.command.is_none() {
|
if args.command.is_none() {
|
||||||
println!("🔧 Watching config.yml...");
|
log::info!("🔧 Watching config.yml...");
|
||||||
println!("🚀 Serial thread launched");
|
log::info!("🚀 Serial thread launched");
|
||||||
println!("🖥️ UI thread launched");
|
log::info!("🖥️ UI thread launched");
|
||||||
let ui_state = ltc_state.clone();
|
let ui_state = ltc_state.clone();
|
||||||
let config_clone = config.clone();
|
let config_clone = config.clone();
|
||||||
let port = "/dev/ttyACM0".to_string();
|
let port = "/dev/ttyACM0".to_string();
|
||||||
|
|
@ -126,10 +128,8 @@ async fn main() {
|
||||||
start_ui(ui_state, port, config_clone);
|
start_ui(ui_state, port, config_clone);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// In daemon mode, we initialize env_logger.
|
// In daemon mode, logging is already set up to go to stderr.
|
||||||
// This will log to stdout, and the systemd service will capture it.
|
// The systemd service will capture it.
|
||||||
// The RUST_LOG env var controls the log level (e.g., RUST_LOG=info).
|
|
||||||
env_logger::init();
|
|
||||||
log::info!("🚀 Starting TimeTurner daemon...");
|
log::info!("🚀 Starting TimeTurner daemon...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,9 +141,12 @@ async fn main() {
|
||||||
{
|
{
|
||||||
let api_state = ltc_state.clone();
|
let api_state = ltc_state.clone();
|
||||||
let config_clone = config.clone();
|
let config_clone = config.clone();
|
||||||
|
let log_buffer_clone = log_buffer.clone();
|
||||||
task::spawn_local(async move {
|
task::spawn_local(async move {
|
||||||
if let Err(e) = start_api_server(api_state, config_clone).await {
|
if let Err(e) =
|
||||||
eprintln!("API server error: {}", 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;
|
std::future::pending::<()>().await;
|
||||||
} else {
|
} else {
|
||||||
// In TUI mode, block on the channel.
|
// In TUI mode, block on the channel.
|
||||||
println!("📡 Main thread entering loop...");
|
log::info!("📡 Main thread entering loop...");
|
||||||
let _ = task::spawn_blocking(move || {
|
let _ = task::spawn_blocking(move || {
|
||||||
for _frame in rx {
|
for _frame in rx {
|
||||||
// no-op
|
// no-op
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,12 @@
|
||||||
<span id="sync-message"></span>
|
<span id="sync-message"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs -->
|
||||||
|
<div class="card full-width">
|
||||||
|
<h2>Logs</h2>
|
||||||
|
<pre id="logs" class="log-box"></pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="script.js"></script>
|
<script src="script.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
jitterStatus: document.getElementById('jitter-status'),
|
jitterStatus: document.getElementById('jitter-status'),
|
||||||
deltaHistory: document.getElementById('delta-history'),
|
deltaHistory: document.getElementById('delta-history'),
|
||||||
interfaces: document.getElementById('interfaces'),
|
interfaces: document.getElementById('interfaces'),
|
||||||
|
logs: document.getElementById('logs'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const hwOffsetInput = document.getElementById('hw-offset');
|
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() {
|
async function triggerManualSync() {
|
||||||
syncMessage.textContent = 'Issuing sync command...';
|
syncMessage.textContent = 'Issuing sync command...';
|
||||||
try {
|
try {
|
||||||
|
|
@ -134,7 +149,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Initial data load
|
// Initial data load
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
|
fetchLogs();
|
||||||
|
|
||||||
// Refresh data every 2 seconds
|
// Refresh data every 2 seconds
|
||||||
setInterval(fetchStatus, 2000);
|
setInterval(fetchStatus, 2000);
|
||||||
|
setInterval(fetchLogs, 2000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue