379 lines
11 KiB
Rust
379 lines
11 KiB
Rust
use std::collections::VecDeque;
|
|
|
|
use fuzzy_matcher::skim::SkimMatcherV2;
|
|
use fuzzy_matcher::FuzzyMatcher;
|
|
use tokio::sync::mpsc::UnboundedReceiver;
|
|
|
|
use crate::config::Config;
|
|
use crate::discovery::{discover_logs, DiscoveryResult, LogSource};
|
|
use crate::log_stream::start_stream;
|
|
use crate::search::SearchState;
|
|
use crate::ssh::SshSession;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Screen {
|
|
Picker,
|
|
Viewer,
|
|
Search,
|
|
PickerSearch,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct LogViewer {
|
|
pub source: LogSource,
|
|
pub buffer: VecDeque<String>,
|
|
pub scroll: usize,
|
|
pub live: bool,
|
|
pub receiver: UnboundedReceiver<String>,
|
|
pub max_lines: usize,
|
|
pub search: SearchState,
|
|
pub search_cursor: Option<usize>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
impl LogViewer {
|
|
pub fn new(source: LogSource, receiver: UnboundedReceiver<String>, max_lines: usize) -> Self {
|
|
Self {
|
|
source,
|
|
buffer: VecDeque::with_capacity(max_lines),
|
|
scroll: 0,
|
|
live: true,
|
|
receiver,
|
|
max_lines,
|
|
search: SearchState::default(),
|
|
search_cursor: None,
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
/// Returns visible lines together with their original buffer index.
|
|
pub fn visible_lines(&self) -> Vec<(usize, &String)> {
|
|
self.buffer
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, l)| self.search.matches(l))
|
|
.collect()
|
|
}
|
|
|
|
pub fn push_line(&mut self, line: String) {
|
|
if let Some(err) = line.strip_prefix("STDERR:") {
|
|
self.error = Some(err.to_string());
|
|
return;
|
|
}
|
|
if self.buffer.len() >= self.max_lines {
|
|
self.buffer.pop_front();
|
|
if self.scroll > 0 {
|
|
self.scroll = self.scroll.saturating_sub(1);
|
|
}
|
|
}
|
|
self.buffer.push_back(line);
|
|
if self.live {
|
|
self.scroll_to_bottom();
|
|
}
|
|
}
|
|
|
|
pub fn scroll_down(&mut self, n: usize) {
|
|
let max = self.buffer.len().saturating_sub(1);
|
|
self.scroll = (self.scroll + n).min(max);
|
|
self.live = false;
|
|
}
|
|
|
|
pub fn scroll_up(&mut self, n: usize) {
|
|
self.scroll = self.scroll.saturating_sub(n);
|
|
if self.scroll < self.buffer.len().saturating_sub(1) {
|
|
self.live = false;
|
|
}
|
|
}
|
|
|
|
pub fn scroll_to_bottom(&mut self) {
|
|
self.scroll = self.buffer.len().saturating_sub(1);
|
|
}
|
|
|
|
pub fn scroll_to_top(&mut self) {
|
|
self.scroll = 0;
|
|
self.live = false;
|
|
}
|
|
|
|
pub fn toggle_live(&mut self) {
|
|
self.live = !self.live;
|
|
if self.live {
|
|
self.scroll_to_bottom();
|
|
}
|
|
}
|
|
|
|
pub fn set_search(&mut self, query: String) {
|
|
self.search.query = query;
|
|
self.search_cursor = None;
|
|
if !self.search.query.is_empty()
|
|
&& let Some(idx) = self.search.next_match(self.buffer.make_contiguous(), self.scroll)
|
|
{
|
|
self.scroll = idx;
|
|
self.search_cursor = Some(idx);
|
|
self.live = false;
|
|
}
|
|
}
|
|
|
|
pub fn next_search_result(&mut self) {
|
|
let lines: Vec<String> = self.buffer.iter().cloned().collect();
|
|
if let Some(idx) = self.search.next_match(&lines, self.scroll) {
|
|
self.scroll = idx;
|
|
self.search_cursor = Some(idx);
|
|
self.live = false;
|
|
}
|
|
}
|
|
|
|
pub fn prev_search_result(&mut self) {
|
|
let lines: Vec<String> = self.buffer.iter().cloned().collect();
|
|
if let Some(idx) = self.search.prev_match(&lines, self.scroll) {
|
|
self.scroll = idx;
|
|
self.search_cursor = Some(idx);
|
|
self.live = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct App {
|
|
pub session: SshSession,
|
|
pub config: Config,
|
|
pub runtime: tokio::runtime::Handle,
|
|
pub logs: Vec<LogSource>,
|
|
pub filtered_indices: Vec<usize>,
|
|
pub selected_index: usize,
|
|
pub screen: Screen,
|
|
pub viewer: Option<LogViewer>,
|
|
pub picker_filter: String,
|
|
pub message: Option<String>,
|
|
pub loading: bool,
|
|
pub discovery_errors: Vec<String>,
|
|
matcher: SkimMatcherV2,
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(session: SshSession, config: Config, runtime: tokio::runtime::Handle) -> Self {
|
|
Self {
|
|
session,
|
|
config,
|
|
runtime,
|
|
logs: Vec::new(),
|
|
filtered_indices: Vec::new(),
|
|
selected_index: 0,
|
|
screen: Screen::Picker,
|
|
viewer: None,
|
|
picker_filter: String::new(),
|
|
message: Some("Press 'r' to load logs".into()),
|
|
loading: false,
|
|
discovery_errors: Vec::new(),
|
|
matcher: SkimMatcherV2::default(),
|
|
}
|
|
}
|
|
|
|
pub async fn discover(&mut self) {
|
|
self.loading = true;
|
|
self.message = Some("Loading logs...".into());
|
|
let result = discover_logs(
|
|
&self.session,
|
|
&self.config.extra_paths,
|
|
self.config.discovery_timeout,
|
|
)
|
|
.await;
|
|
self.apply_discovery(result);
|
|
self.loading = false;
|
|
}
|
|
|
|
fn apply_discovery(&mut self, result: DiscoveryResult) {
|
|
self.logs = result.sources;
|
|
self.discovery_errors = result.errors;
|
|
self.apply_picker_filter();
|
|
self.selected_index = 0;
|
|
if self.logs.is_empty() {
|
|
self.message = Some("No logs found.".into());
|
|
} else {
|
|
self.message = Some(format!("Found {} logs", self.logs.len()));
|
|
}
|
|
}
|
|
|
|
pub fn apply_picker_filter(&mut self) {
|
|
if self.picker_filter.is_empty() {
|
|
self.filtered_indices = (0..self.logs.len()).collect();
|
|
} else {
|
|
let mut scored: Vec<(i64, usize)> = self
|
|
.logs
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(i, log)| {
|
|
self.matcher
|
|
.fuzzy_match(&log.search_text(), &self.picker_filter)
|
|
.map(|score| (score, i))
|
|
})
|
|
.collect();
|
|
scored.sort_by_key(|b| std::cmp::Reverse(b.0));
|
|
self.filtered_indices = scored.into_iter().map(|(_, i)| i).collect();
|
|
}
|
|
self.selected_index = self
|
|
.selected_index
|
|
.min(self.filtered_indices.len().saturating_sub(1));
|
|
}
|
|
|
|
pub fn selected_log(&self) -> Option<&LogSource> {
|
|
self.filtered_indices
|
|
.get(self.selected_index)
|
|
.and_then(|&i| self.logs.get(i))
|
|
}
|
|
|
|
pub fn next_log(&mut self) {
|
|
if !self.filtered_indices.is_empty() {
|
|
self.selected_index = (self.selected_index + 1) % self.filtered_indices.len();
|
|
}
|
|
}
|
|
|
|
pub fn previous_log(&mut self) {
|
|
if !self.filtered_indices.is_empty() {
|
|
self.selected_index = self
|
|
.selected_index
|
|
.checked_sub(1)
|
|
.unwrap_or(self.filtered_indices.len() - 1);
|
|
}
|
|
}
|
|
|
|
pub fn open_selected_log(&mut self) -> std::io::Result<()> {
|
|
let Some(source) = self.selected_log().cloned() else {
|
|
return Ok(());
|
|
};
|
|
let session = self.session.clone();
|
|
let stream_source = source.clone();
|
|
let rx = self.runtime.block_on(async move {
|
|
start_stream(&session, &stream_source, 1000)
|
|
})?;
|
|
self.viewer = Some(LogViewer::new(source, rx, self.config.max_log_lines));
|
|
self.screen = Screen::Viewer;
|
|
self.message = None;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn close_viewer(&mut self) {
|
|
self.viewer = None;
|
|
self.screen = Screen::Picker;
|
|
self.message = None;
|
|
}
|
|
|
|
pub fn update_stream(&mut self) {
|
|
if let Some(viewer) = &mut self.viewer {
|
|
while let Ok(line) = viewer.receiver.try_recv() {
|
|
viewer.push_line(line);
|
|
}
|
|
if let Some(err) = viewer.error.take() {
|
|
self.message = Some(format!("SSH error: {}", err));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn enter_search(&mut self) {
|
|
self.screen = match self.screen {
|
|
Screen::Picker => Screen::PickerSearch,
|
|
Screen::Viewer => Screen::Search,
|
|
_ => self.screen,
|
|
};
|
|
}
|
|
|
|
pub fn exit_search(&mut self) {
|
|
self.screen = match self.screen {
|
|
Screen::PickerSearch => Screen::Picker,
|
|
Screen::Search => Screen::Viewer,
|
|
_ => self.screen,
|
|
};
|
|
if let Some(v) = &mut self.viewer {
|
|
v.search = SearchState::default();
|
|
v.search_cursor = None;
|
|
}
|
|
}
|
|
|
|
pub fn confirm_search(&mut self) {
|
|
self.screen = match self.screen {
|
|
Screen::PickerSearch => Screen::Picker,
|
|
Screen::Search => Screen::Viewer,
|
|
_ => self.screen,
|
|
};
|
|
}
|
|
|
|
pub fn current_filter_query(&self) -> &str {
|
|
match self.screen {
|
|
Screen::PickerSearch => &self.picker_filter,
|
|
Screen::Search => self.viewer.as_ref().map(|v| v.search.query.as_str()).unwrap_or(""),
|
|
_ => "",
|
|
}
|
|
}
|
|
|
|
pub fn current_filter_query_mut(&mut self) -> Option<&mut String> {
|
|
match self.screen {
|
|
Screen::PickerSearch => Some(&mut self.picker_filter),
|
|
Screen::Search => self.viewer.as_mut().map(|v| &mut v.search.query),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn apply_current_filter(&mut self) {
|
|
if self.screen == Screen::PickerSearch {
|
|
self.apply_picker_filter();
|
|
} else if let Some(v) = &mut self.viewer {
|
|
let query = v.search.query.clone();
|
|
v.set_search(query);
|
|
}
|
|
}
|
|
|
|
pub fn save_viewer_buffer(&self) -> Option<String> {
|
|
let viewer = self.viewer.as_ref()?;
|
|
let name = format!(
|
|
"lview-{}-{}",
|
|
viewer.source.to_string().replace(['/', ' ', ':'], "_"),
|
|
chrono::Local::now().format("%Y%m%d-%H%M%S")
|
|
);
|
|
let path = std::env::temp_dir().join(name);
|
|
let content: String = viewer
|
|
.buffer
|
|
.iter()
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
std::fs::write(&path, content).ok()?;
|
|
Some(path.to_string_lossy().to_string())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_log_viewer_push_and_scroll() {
|
|
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut v = LogViewer::new(
|
|
LogSource::File { path: "/tmp/test.log".into() },
|
|
rx,
|
|
100,
|
|
);
|
|
for i in 0..5 {
|
|
v.push_line(format!("line {}", i));
|
|
}
|
|
assert_eq!(v.buffer.len(), 5);
|
|
assert_eq!(v.scroll, 4);
|
|
v.scroll_up(2);
|
|
assert_eq!(v.scroll, 2);
|
|
v.toggle_live();
|
|
assert!(v.live);
|
|
assert_eq!(v.scroll, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_app_filter() {
|
|
let session = SshSession::new("ssh".into(), crate::ssh::SshTarget { args: vec!["host".into()] });
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
let mut app = App::new(session, Config::default(), rt.handle().clone());
|
|
app.logs = vec![
|
|
LogSource::File { path: "/var/log/syslog".into() },
|
|
LogSource::File { path: "/opt/app/app.log".into() },
|
|
];
|
|
app.picker_filter = "syslog".into();
|
|
app.apply_picker_filter();
|
|
assert_eq!(app.filtered_indices, vec![0]);
|
|
}
|
|
}
|