From e15c69052b7d9b38d393a3afb4247857b66f424e Mon Sep 17 00:00:00 2001 From: Matthew Hounslow Date: Wed, 24 Dec 2025 00:35:51 -0700 Subject: [PATCH] feat(tui): Add CSV export format - Add export_state_csv() method to App - Add 'E' keybinding for CSV export (vs 'e' for JSON) - CSV includes: Module, Topic, Type, Count, Backlog, Pending, Health - Add csv_escape() helper for proper CSV formatting - Update help text with new keybinding Closes #32 --- CHANGELOG.md | 1 + buswatch-tui/src/app.rs | 61 +++++++++++++++++++++++++++++++++++ buswatch-tui/src/events.rs | 15 ++++++++- buswatch-tui/src/ui/common.rs | 1 + 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56894c0..719fe2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All metrics include `module` and `topic` labels - Health check endpoints (`/health`, `/healthz`) for Kubernetes probes - Metrics: read/write counts, backlog, pending seconds, rates +- **buswatch-tui**: CSV export format (press `E` for CSV, `e` for JSON) ## [0.1.0] - 2025-12-21 diff --git a/buswatch-tui/src/app.rs b/buswatch-tui/src/app.rs index 8fde6bc..cee0b52 100644 --- a/buswatch-tui/src/app.rs +++ b/buswatch-tui/src/app.rs @@ -533,4 +533,65 @@ impl App { Ok(()) } + + /// Export current state to a CSV file. + pub fn export_state_csv(&self, path: &std::path::Path) -> anyhow::Result<()> { + use crate::data::duration::format_duration; + use std::io::Write; + + let Some(ref data) = self.data else { + anyhow::bail!("No data to export"); + }; + + let mut output = String::new(); + + // CSV header + output.push_str("Module,Topic,Type,Count,Backlog,Pending,Health\n"); + + // Write rows for each module's topics + for module in &data.modules { + // Read topics + for read in &module.reads { + output.push_str(&format!( + "{},{},{},{},{},{},{}\n", + csv_escape(&module.name), + csv_escape(&read.topic), + "Read", + read.read, + read.unread.map(|u| u.to_string()).unwrap_or_default(), + read.pending_for.map(format_duration).unwrap_or_default(), + read.status.symbol() + )); + } + + // Write topics + for write in &module.writes { + output.push_str(&format!( + "{},{},{},{},{},{},{}\n", + csv_escape(&module.name), + csv_escape(&write.topic), + "Write", + write.written, + "", // writes don't have backlog + write.pending_for.map(format_duration).unwrap_or_default(), + write.status.symbol() + )); + } + } + + let mut file = std::fs::File::create(path)?; + file.write_all(output.as_bytes())?; + + Ok(()) + } +} + +/// Escape a string for CSV format. +/// Wraps in quotes if the string contains comma, quote, or newline. +fn csv_escape(s: &str) -> String { + if s.contains(',') || s.contains('"') || s.contains('\n') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } } diff --git a/buswatch-tui/src/events.rs b/buswatch-tui/src/events.rs index 9e8ee13..98f79f3 100644 --- a/buswatch-tui/src/events.rs +++ b/buswatch-tui/src/events.rs @@ -127,7 +127,7 @@ pub fn handle_key_event(app: &mut App, key: KeyEvent) { } } - // Export + // Export JSON KeyCode::Char('e') => { let export_path = std::path::PathBuf::from("monitor_export.json"); match app.export_state(&export_path) { @@ -140,6 +140,19 @@ pub fn handle_key_event(app: &mut App, key: KeyEvent) { } } + // Export CSV + KeyCode::Char('E') => { + let export_path = std::path::PathBuf::from("monitor_export.csv"); + match app.export_state_csv(&export_path) { + Ok(()) => { + app.set_status_message(format!("Exported to {}", export_path.display())); + } + Err(e) => { + app.set_status_message(format!("Export failed: {}", e)); + } + } + } + _ => {} } } diff --git a/buswatch-tui/src/ui/common.rs b/buswatch-tui/src/ui/common.rs index 21d4cc1..7fd03b7 100644 --- a/buswatch-tui/src/ui/common.rs +++ b/buswatch-tui/src/ui/common.rs @@ -225,6 +225,7 @@ pub fn render_help(frame: &mut Frame, app: &App, area: Rect) { )]), Line::from(" r Reload data"), Line::from(" e Export to JSON"), + Line::from(" E Export to CSV"), Line::from(" q Quit"), Line::from(""), Line::from(vec![Span::styled(