Add log-to-file feature with background writer
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
use crate::app_state::{self, PersistedGuiState};
|
use crate::app_state::{self, PersistedGuiState, SessionLogConfig};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
||||||
|
use crate::logging::{self, LogWriter};
|
||||||
use crate::panels::{config, console, hex_view, plot_view, sidebar};
|
use crate::panels::{config, console, hex_view, plot_view, sidebar};
|
||||||
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
||||||
use crate::shortcuts::{self, default_bindings};
|
use crate::shortcuts::{self, default_bindings};
|
||||||
@@ -156,6 +158,9 @@ pub struct SessionTab {
|
|||||||
pub line_ending: LineEnding,
|
pub line_ending: LineEnding,
|
||||||
pub send_status: Option<String>,
|
pub send_status: Option<String>,
|
||||||
pub search: SearchState,
|
pub search: SearchState,
|
||||||
|
pub log_enabled: bool,
|
||||||
|
pub log_path: String,
|
||||||
|
pub log_writer: Option<LogWriter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct XserialApp {
|
pub struct XserialApp {
|
||||||
@@ -375,8 +380,17 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn restore_saved_sessions(&mut self, saved_state: PersistedGuiState) {
|
fn restore_saved_sessions(&mut self, saved_state: PersistedGuiState) {
|
||||||
for session_config in saved_state.sessions {
|
for (i, session_config) in saved_state.sessions.into_iter().enumerate() {
|
||||||
self.add_session_tab(session_config);
|
self.add_session_tab(session_config);
|
||||||
|
if let Some(tab) = self.tabs.last_mut() {
|
||||||
|
if let Some(log_cfg) = saved_state.sessions_log.get(i) {
|
||||||
|
tab.log_enabled = log_cfg.enabled;
|
||||||
|
tab.log_path = log_cfg.file_path.clone();
|
||||||
|
if log_cfg.enabled && !log_cfg.file_path.is_empty() {
|
||||||
|
tab.log_writer = LogWriter::open(&log_cfg.file_path).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !self.tabs.is_empty() {
|
if !self.tabs.is_empty() {
|
||||||
self.active = saved_state.active.min(self.tabs.len() - 1);
|
self.active = saved_state.active.min(self.tabs.len() - 1);
|
||||||
@@ -408,6 +422,9 @@ impl XserialApp {
|
|||||||
line_ending: LineEnding::None,
|
line_ending: LineEnding::None,
|
||||||
send_status: None,
|
send_status: None,
|
||||||
search: SearchState::default(),
|
search: SearchState::default(),
|
||||||
|
log_enabled: false,
|
||||||
|
log_path: String::new(),
|
||||||
|
log_writer: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +435,14 @@ impl XserialApp {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|tab| tab.session_config.clone())
|
.map(|tab| tab.session_config.clone())
|
||||||
.collect(),
|
.collect(),
|
||||||
|
sessions_log: self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.map(|tab| SessionLogConfig {
|
||||||
|
enabled: tab.log_enabled,
|
||||||
|
file_path: tab.log_path.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
active: if self.tabs.is_empty() {
|
active: if self.tabs.is_empty() {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -496,6 +521,17 @@ impl XserialApp {
|
|||||||
SessionEvent::Data(id, entry) => {
|
SessionEvent::Data(id, entry) => {
|
||||||
stats.drained_data_events += 1;
|
stats.drained_data_events += 1;
|
||||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
||||||
|
if let Some(ref writer) = tab.log_writer {
|
||||||
|
if let Some(line) = logging::format_data_log(
|
||||||
|
&entry,
|
||||||
|
"IN",
|
||||||
|
self.display.show_timestamp,
|
||||||
|
self.display.show_direction,
|
||||||
|
self.display.show_pipeline,
|
||||||
|
) {
|
||||||
|
writer.write_line(&line);
|
||||||
|
}
|
||||||
|
}
|
||||||
match &entry.data {
|
match &entry.data {
|
||||||
DecodedData::Text(_) => {
|
DecodedData::Text(_) => {
|
||||||
stats.drained_text_events += 1;
|
stats.drained_text_events += 1;
|
||||||
@@ -1259,6 +1295,31 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
|
|||||||
});
|
});
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
|
|
||||||
|
let log_toggled = ui
|
||||||
|
.checkbox(&mut tab.log_enabled, "Log to file")
|
||||||
|
.changed();
|
||||||
|
if log_toggled {
|
||||||
|
if tab.log_enabled {
|
||||||
|
tab.log_path = default_log_path(tab.id)
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
tab.log_writer = LogWriter::open(&tab.log_path).ok();
|
||||||
|
} else {
|
||||||
|
tab.log_writer = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tab.log_enabled {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let changed = ui
|
||||||
|
.text_edit_singleline(&mut tab.log_path)
|
||||||
|
.lost_focus();
|
||||||
|
if changed && !tab.log_path.is_empty() {
|
||||||
|
tab.log_writer = LogWriter::open(&tab.log_path).ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.add_space(3.0);
|
||||||
|
|
||||||
let hint = match tab.send_mode {
|
let hint = match tab.send_mode {
|
||||||
SendMode::Text => "Enter text to send",
|
SendMode::Text => "Enter text to send",
|
||||||
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
||||||
@@ -1304,7 +1365,10 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
|
|||||||
LineEnding::CR => text.push('\r'),
|
LineEnding::CR => text.push('\r'),
|
||||||
LineEnding::CRLF => text.push_str("\r\n"),
|
LineEnding::CRLF => text.push_str("\r\n"),
|
||||||
}
|
}
|
||||||
tab.console.push_outbound(text);
|
tab.console.push_outbound(text.clone());
|
||||||
|
if let Some(ref writer) = tab.log_writer {
|
||||||
|
writer.write_line(&logging::format_sent_log(&text));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SendMode::Hex => {
|
SendMode::Hex => {
|
||||||
@@ -1314,7 +1378,10 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
if !hex.is_empty() {
|
if !hex.is_empty() {
|
||||||
tab.hex.push_outbound(hex);
|
tab.hex.push_outbound(hex.clone());
|
||||||
|
if let Some(ref writer) = tab.log_writer {
|
||||||
|
writer.write_line(&logging::format_sent_log(&hex));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1336,6 +1403,16 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_log_path(session_id: u64) -> PathBuf {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let dir = app_state::config_dir().join("logs");
|
||||||
|
let ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
dir.join(format!("session_{session_id}_{ts}.log"))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
|
fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
|
||||||
let trimmed = tab.send_input.trim();
|
let trimmed = tab.send_input.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
|
|||||||
@@ -9,11 +9,30 @@ use xserial_client::config::SessionConfig;
|
|||||||
|
|
||||||
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionLogConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub file_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SessionLogConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
file_path: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct PersistedGuiState {
|
pub struct PersistedGuiState {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sessions: Vec<SessionConfig>,
|
pub sessions: Vec<SessionConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub sessions_log: Vec<SessionLogConfig>,
|
||||||
|
#[serde(default)]
|
||||||
pub active: usize,
|
pub active: usize,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_timestamp: bool,
|
pub show_timestamp: bool,
|
||||||
@@ -44,6 +63,14 @@ fn gui_state_path() -> PathBuf {
|
|||||||
gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the xserial configuration directory (the parent of gui-state.json).
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
gui_state_path()
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
|
||||||
fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
||||||
match os {
|
match os {
|
||||||
"windows" => {
|
"windows" => {
|
||||||
@@ -137,6 +164,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
..SessionConfig::default()
|
..SessionConfig::default()
|
||||||
}],
|
}],
|
||||||
|
sessions_log: vec![SessionLogConfig::default()],
|
||||||
active: 0,
|
active: 0,
|
||||||
show_timestamp: false,
|
show_timestamp: false,
|
||||||
show_direction: true,
|
show_direction: true,
|
||||||
|
|||||||
109
crates/xserial-gui/src/logging.rs
Normal file
109
crates/xserial-gui/src/logging.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::{BufWriter, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use xserial_client::event::DecodedEntry;
|
||||||
|
use xserial_core::protocol::DecodedData;
|
||||||
|
|
||||||
|
/// Manages background file logging via a dedicated writer thread.
|
||||||
|
///
|
||||||
|
/// Lines are sent through an mpsc channel to a background thread that
|
||||||
|
/// appends to the log file using buffered I/O, keeping the GUI thread
|
||||||
|
/// non-blocking.
|
||||||
|
pub struct LogWriter {
|
||||||
|
sender: mpsc::Sender<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogWriter {
|
||||||
|
/// Spawns a background thread that opens `path` in append mode and
|
||||||
|
/// writes every line it receives through the returned `LogWriter`.
|
||||||
|
pub fn open(path: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||||
|
let path: PathBuf = path.into();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||||
|
let mut writer = BufWriter::with_capacity(1024, file);
|
||||||
|
let (sender, receiver) = mpsc::channel::<String>();
|
||||||
|
|
||||||
|
let thread_name = format!("xserial-log-{}", path.display());
|
||||||
|
thread::Builder::new().name(thread_name).spawn(move || {
|
||||||
|
while let Ok(line) = receiver.recv() {
|
||||||
|
if writeln!(writer, "{line}").is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = writer.flush();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self { sender })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a line to the background writer. Non-blocking — drops the line
|
||||||
|
/// silently if the writer thread has stopped.
|
||||||
|
pub fn write_line(&self, line: &str) {
|
||||||
|
let _ = self.sender.send(line.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LogWriter {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Sender is dropped here, which causes the background thread's
|
||||||
|
// receiver.recv() to return Err, triggering flush + exit.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a [`DecodedEntry`] into a log line.
|
||||||
|
///
|
||||||
|
/// Format: `[HH:MM:SS.mmm] [IN] [pipeline] payload`
|
||||||
|
///
|
||||||
|
/// Returns `None` for Binary and Plot entries (not meaningful as text logs).
|
||||||
|
pub fn format_data_log(
|
||||||
|
entry: &DecodedEntry,
|
||||||
|
direction: &str,
|
||||||
|
show_timestamp: bool,
|
||||||
|
show_direction: bool,
|
||||||
|
show_pipeline: bool,
|
||||||
|
) -> Option<String> {
|
||||||
|
let data = match &entry.data {
|
||||||
|
DecodedData::Text(text) => text.clone(),
|
||||||
|
DecodedData::Hex(hex) => hex.clone(),
|
||||||
|
DecodedData::Binary(_) | DecodedData::Plot(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts = Vec::with_capacity(4);
|
||||||
|
if show_timestamp {
|
||||||
|
parts.push(utc_timestamp());
|
||||||
|
}
|
||||||
|
if show_direction {
|
||||||
|
parts.push(format!("[{direction}]"));
|
||||||
|
}
|
||||||
|
if show_pipeline {
|
||||||
|
parts.push(format!("[{}]", entry.pipeline_name));
|
||||||
|
}
|
||||||
|
parts.push(data);
|
||||||
|
|
||||||
|
Some(parts.join(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a sent text string into a log line (no `DecodedEntry` available
|
||||||
|
/// for outbound data).
|
||||||
|
pub fn format_sent_log(text: &str) -> String {
|
||||||
|
format!("{} [OUT] {text}", utc_timestamp())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utc_timestamp() -> String {
|
||||||
|
let since_epoch = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let secs = since_epoch.as_secs();
|
||||||
|
let ms = since_epoch.subsec_millis();
|
||||||
|
let h = (secs / 3600) % 24;
|
||||||
|
let m = (secs / 60) % 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
format!("{h:02}:{m:02}:{s:02}.{ms:03}")
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
mod buffers;
|
mod buffers;
|
||||||
|
mod logging;
|
||||||
mod panels;
|
mod panels;
|
||||||
mod perf;
|
mod perf;
|
||||||
mod shortcuts;
|
mod shortcuts;
|
||||||
|
|||||||
Reference in New Issue
Block a user