Initial lview implementation
This commit is contained in:
commit
533c5ae637
13 changed files with 3526 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
||||||
2055
Cargo.lock
generated
Normal file
2055
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[package]
|
||||||
|
name = "lview"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
authors = ["janvete"]
|
||||||
|
description = "TUI for viewing remote logs over SSH"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/janvete/lview"
|
||||||
|
homepage = "https://github.com/janvete/lview"
|
||||||
|
keywords = ["cli", "tui", "ssh", "logs", "docker"]
|
||||||
|
categories = ["command-line-utilities"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = "0.4.45"
|
||||||
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
|
crossterm = "0.29.0"
|
||||||
|
directories = "6.0.0"
|
||||||
|
fuzzy-matcher = "0.3.7"
|
||||||
|
ratatui = "0.30.1"
|
||||||
|
regex = "1.11.1"
|
||||||
|
serde = { version = "1.0.217", features = ["derive"] }
|
||||||
|
tokio = { version = "1.43.0", features = ["rt-multi-thread", "process", "io-util", "sync", "time", "macros"] }
|
||||||
|
toml = "0.8.20"
|
||||||
66
README.md
Normal file
66
README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# lview
|
||||||
|
|
||||||
|
TUI pro prohlížení logů na vzdálených Linux strojích přes SSH. Automaticky detekuje systémové logy, Docker kontejnery, `/opt` aplikace a systemd journal.
|
||||||
|
|
||||||
|
## Instalace
|
||||||
|
|
||||||
|
### macOS (Homebrew)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap janvete/tools
|
||||||
|
brew install lview
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ze zdroje
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install --git https://github.com/janvete/lview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Použití
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lview ssh -p22 root@192.168.53.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Všechny argumenty za `ssh` jsou předány přímo systémovému `ssh` příkazu, takže se použije vaše `~/.ssh/config`, SSH agent a klíče.
|
||||||
|
|
||||||
|
## Ovládání
|
||||||
|
|
||||||
|
### Výběr logu
|
||||||
|
|
||||||
|
| Klávesa | Akce |
|
||||||
|
|---------|------|
|
||||||
|
| `j` / `k` nebo `↓` / `↑` | pohyb v seznamu |
|
||||||
|
| `Enter` | otevřít vybraný log |
|
||||||
|
| `/` | fuzzy filtrování seznamu logů |
|
||||||
|
| `r` | znovu načíst seznam logů |
|
||||||
|
| `q` | ukončit |
|
||||||
|
|
||||||
|
### Prohlížeč logu
|
||||||
|
|
||||||
|
| Klávesa | Akce |
|
||||||
|
|---------|------|
|
||||||
|
| `j` / `k` nebo `↓` / `↑` | scroll |
|
||||||
|
| `Ctrl+d` / `Ctrl+u` | scroll o 10 řádků |
|
||||||
|
| `g` / `G` | začátek / konec |
|
||||||
|
| `l` nebo `mezerník` | zapnout/vypnout živý náhled |
|
||||||
|
| `/` | vyhledávání v logu (regex, case-insensitive) |
|
||||||
|
| `n` / `N` | další / předchozí výskyt |
|
||||||
|
| `s` | uložit aktuální buffer do `/tmp` |
|
||||||
|
| `q` nebo `Esc` | zpět do výběru |
|
||||||
|
|
||||||
|
## Konfigurace
|
||||||
|
|
||||||
|
Vytvořte `~/.config/lview/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
ssh_command = "ssh"
|
||||||
|
max_log_lines = 10000
|
||||||
|
discovery_timeout = 10
|
||||||
|
extra_paths = ["/custom/logs"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
358
src/app.rs
Normal file
358
src/app.rs
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visible_lines(&self) -> Vec<&String> {
|
||||||
|
self.buffer.iter().filter(|l| self.search.matches(l)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_line(&mut self, line: String) {
|
||||||
|
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 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) -> Self {
|
||||||
|
Self {
|
||||||
|
session,
|
||||||
|
config,
|
||||||
|
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.to_string(), &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 rx = start_stream(&self.session, &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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mut app = App::new(session, Config::default());
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/config.rs
Normal file
78
src/config.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_ssh_command")]
|
||||||
|
pub ssh_command: String,
|
||||||
|
#[serde(default = "default_max_log_lines")]
|
||||||
|
pub max_log_lines: usize,
|
||||||
|
#[serde(default = "default_discovery_timeout")]
|
||||||
|
pub discovery_timeout: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_paths: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ssh_command: default_ssh_command(),
|
||||||
|
max_log_lines: default_max_log_lines(),
|
||||||
|
discovery_timeout: default_discovery_timeout(),
|
||||||
|
extra_paths: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let path = Self::config_path();
|
||||||
|
if path.exists() {
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(content) => match toml::from_str(&content) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: failed to parse config {}: {}. Using defaults.",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: failed to read config {}: {}. Using defaults.",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_path() -> PathBuf {
|
||||||
|
match ProjectDirs::from("com", "janvete", "lview") {
|
||||||
|
Some(dirs) => dirs.config_dir().join("config.toml"),
|
||||||
|
None => PathBuf::from(".").join("lview-config.toml"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ssh_command() -> String {
|
||||||
|
"ssh".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_log_lines() -> usize {
|
||||||
|
10_000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_discovery_timeout() -> u64 {
|
||||||
|
10
|
||||||
|
}
|
||||||
189
src/discovery.rs
Normal file
189
src/discovery.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use crate::ssh::SshSession;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum LogSource {
|
||||||
|
File { path: String },
|
||||||
|
Docker { container: String, image: String },
|
||||||
|
Journal { unit: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for LogSource {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
LogSource::File { path } => write!(f, "{}", path),
|
||||||
|
LogSource::Docker { container, image } => write!(f, "{} ({})", container, image),
|
||||||
|
LogSource::Journal { unit } => write!(f, "{}", unit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogSource {
|
||||||
|
pub fn category(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LogSource::File { path } => {
|
||||||
|
if path.starts_with("/opt/") {
|
||||||
|
"opt"
|
||||||
|
} else if path.starts_with("/var/log/") {
|
||||||
|
"system"
|
||||||
|
} else {
|
||||||
|
"other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogSource::Docker { .. } => "docker",
|
||||||
|
LogSource::Journal { .. } => "journal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_key(&self) -> String {
|
||||||
|
format!("{}:{}", self.category(), self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DiscoveryResult {
|
||||||
|
pub sources: Vec<LogSource>,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn discover_logs(session: &SshSession, extra_paths: &[String], timeout_secs: u64) -> DiscoveryResult {
|
||||||
|
let mut result = DiscoveryResult {
|
||||||
|
sources: Vec::new(),
|
||||||
|
errors: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let system = discover_system_logs(session, timeout_secs);
|
||||||
|
let docker = discover_docker(session, timeout_secs);
|
||||||
|
let opt = discover_opt_logs(session, extra_paths, timeout_secs);
|
||||||
|
let journal = discover_journal(session, timeout_secs);
|
||||||
|
|
||||||
|
let (system, docker, opt, journal) = tokio::join!(system, docker, opt, journal);
|
||||||
|
|
||||||
|
match system {
|
||||||
|
Ok(items) => result.sources.extend(items),
|
||||||
|
Err(e) => result.errors.push(format!("system logs: {}", e)),
|
||||||
|
}
|
||||||
|
match docker {
|
||||||
|
Ok(items) => result.sources.extend(items),
|
||||||
|
Err(e) => result.errors.push(format!("docker: {}", e)),
|
||||||
|
}
|
||||||
|
match opt {
|
||||||
|
Ok(items) => result.sources.extend(items),
|
||||||
|
Err(e) => result.errors.push(format!("/opt logs: {}", e)),
|
||||||
|
}
|
||||||
|
match journal {
|
||||||
|
Ok(items) => result.sources.extend(items),
|
||||||
|
Err(e) => result.errors.push(format!("journal: {}", e)),
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sources.sort_by_key(|a| a.sort_key());
|
||||||
|
result.sources.dedup();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discover_system_logs(session: &SshSession, timeout_secs: u64) -> Result<Vec<LogSource>, String> {
|
||||||
|
let cmd = "find /var/log -maxdepth 3 -type f 2>/dev/null | head -n 200";
|
||||||
|
let out = session.exec(cmd, timeout_secs).await.map_err(|e| e.to_string())?;
|
||||||
|
let text = String::from_utf8_lossy(&out);
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
let path = line.trim();
|
||||||
|
if !path.is_empty() {
|
||||||
|
items.push(LogSource::File { path: path.to_string() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discover_docker(session: &SshSession, timeout_secs: u64) -> Result<Vec<LogSource>, String> {
|
||||||
|
let cmd = "docker ps --format '{{.Names}}\\t{{.Image}}' 2>/dev/null | head -n 200";
|
||||||
|
let out = session.exec(cmd, timeout_secs).await.map_err(|e| e.to_string())?;
|
||||||
|
let text = String::from_utf8_lossy(&out);
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
let parts: Vec<&str> = line.split('\t').collect();
|
||||||
|
if parts.len() >= 2 {
|
||||||
|
items.push(LogSource::Docker {
|
||||||
|
container: parts[0].trim().to_string(),
|
||||||
|
image: parts[1].trim().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discover_opt_logs(
|
||||||
|
session: &SshSession,
|
||||||
|
extra_paths: &[String],
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<Vec<LogSource>, String> {
|
||||||
|
let mut paths = vec!["/opt".to_string()];
|
||||||
|
paths.extend(extra_paths.iter().cloned());
|
||||||
|
let paths_str = paths
|
||||||
|
.iter()
|
||||||
|
.map(|p| shell_escape(p))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
let cmd = format!(
|
||||||
|
"find {} -maxdepth 3 -type f \\( -name '*.log' -o -name 'docker-compose.yml' -o -name 'compose.yml' \\) 2>/dev/null | head -n 200",
|
||||||
|
paths_str
|
||||||
|
);
|
||||||
|
let out = session.exec(&cmd, timeout_secs).await.map_err(|e| e.to_string())?;
|
||||||
|
let text = String::from_utf8_lossy(&out);
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
let path = line.trim();
|
||||||
|
if !path.is_empty() {
|
||||||
|
items.push(LogSource::File { path: path.to_string() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discover_journal(session: &SshSession, timeout_secs: u64) -> Result<Vec<LogSource>, String> {
|
||||||
|
let cmd = "systemctl list-units --type=service --state=running --no-pager --plain 2>/dev/null | tail -n +2 | head -n 100";
|
||||||
|
let out = session.exec(cmd, timeout_secs).await.map_err(|e| e.to_string())?;
|
||||||
|
let text = String::from_utf8_lossy(&out);
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if let Some(unit) = parts.first()
|
||||||
|
&& unit.ends_with(".service")
|
||||||
|
{
|
||||||
|
items.push(LogSource::Journal {
|
||||||
|
unit: unit.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_escape(s: &str) -> String {
|
||||||
|
if s.contains(' ') || s.contains('"') || s.contains('\\') || s.contains('\'') {
|
||||||
|
format!("'{}'", s.replace('\'', "'\"'\"'"))
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_categories() {
|
||||||
|
assert_eq!(
|
||||||
|
LogSource::File { path: "/var/log/syslog".into() }.category(),
|
||||||
|
"system"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
LogSource::File { path: "/opt/app/app.log".into() }.category(),
|
||||||
|
"opt"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
LogSource::Docker { container: "c".into(), image: "i".into() }.category(),
|
||||||
|
"docker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/events.rs
Normal file
121
src/events.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
|
use crate::app::{App, Screen};
|
||||||
|
|
||||||
|
/// Returns true if the app should keep running.
|
||||||
|
pub fn handle_event(app: &mut App, event: Event) -> bool {
|
||||||
|
match event {
|
||||||
|
Event::Key(key) if key.kind == KeyEventKind::Press => handle_key(app, key),
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(app: &mut App, key: KeyEvent) -> bool {
|
||||||
|
// Global quit
|
||||||
|
if key.code == KeyCode::Char('q')
|
||||||
|
&& !matches!(app.screen, Screen::Search | Screen::PickerSearch)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match app.screen {
|
||||||
|
Screen::Picker => handle_picker(app, key),
|
||||||
|
Screen::Viewer => handle_viewer(app, key),
|
||||||
|
Screen::Search => handle_search(app, key),
|
||||||
|
Screen::PickerSearch => handle_picker_search(app, key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_picker(app: &mut App, key: KeyEvent) -> bool {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Char('Q') => return false,
|
||||||
|
KeyCode::Char('j') | KeyCode::Down => app.next_log(),
|
||||||
|
KeyCode::Char('k') | KeyCode::Up => app.previous_log(),
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Err(e) = app.open_selected_log() {
|
||||||
|
app.message = Some(format!("Failed to open log: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('/') => app.enter_search(),
|
||||||
|
KeyCode::Char('r') => {
|
||||||
|
// Async discovery is triggered from main loop
|
||||||
|
app.loading = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_viewer(app: &mut App, key: KeyEvent) -> bool {
|
||||||
|
let Some(viewer) = app.viewer.as_mut() else {
|
||||||
|
app.screen = Screen::Picker;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => app.close_viewer(),
|
||||||
|
KeyCode::Char('j') | KeyCode::Down => viewer.scroll_down(1),
|
||||||
|
KeyCode::Char('k') | KeyCode::Up => viewer.scroll_up(1),
|
||||||
|
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => viewer.scroll_down(10),
|
||||||
|
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => viewer.scroll_up(10),
|
||||||
|
KeyCode::Char('g') => viewer.scroll_to_top(),
|
||||||
|
KeyCode::Char('G') => viewer.scroll_to_bottom(),
|
||||||
|
KeyCode::Char('l') | KeyCode::Char(' ') => viewer.toggle_live(),
|
||||||
|
KeyCode::Char('/') => app.enter_search(),
|
||||||
|
KeyCode::Char('n') => {
|
||||||
|
viewer.next_search_result();
|
||||||
|
}
|
||||||
|
KeyCode::Char('N') => {
|
||||||
|
viewer.prev_search_result();
|
||||||
|
}
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
if let Some(path) = app.save_viewer_buffer() {
|
||||||
|
app.message = Some(format!("Saved to {}", path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_search(app: &mut App, key: KeyEvent) -> bool {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.exit_search(),
|
||||||
|
KeyCode::Enter => app.confirm_search(),
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if let Some(query) = app.current_filter_query_mut() {
|
||||||
|
query.pop();
|
||||||
|
app.apply_current_filter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
if let Some(query) = app.current_filter_query_mut() {
|
||||||
|
query.push(c);
|
||||||
|
app.apply_current_filter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_picker_search(app: &mut App, key: KeyEvent) -> bool {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.exit_search(),
|
||||||
|
KeyCode::Enter => app.confirm_search(),
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if let Some(query) = app.current_filter_query_mut() {
|
||||||
|
query.pop();
|
||||||
|
app.apply_current_filter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
if let Some(query) = app.current_filter_query_mut() {
|
||||||
|
query.push(c);
|
||||||
|
app.apply_current_filter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
51
src/log_stream.rs
Normal file
51
src/log_stream.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
use crate::discovery::LogSource;
|
||||||
|
use crate::ssh::SshSession;
|
||||||
|
|
||||||
|
pub fn start_stream(session: &SshSession, source: &LogSource, tail_lines: usize) -> std::io::Result<tokio::sync::mpsc::UnboundedReceiver<String>> {
|
||||||
|
let remote_command = build_command(source, tail_lines);
|
||||||
|
session.stream_lines(&remote_command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_command(source: &LogSource, tail_lines: usize) -> String {
|
||||||
|
match source {
|
||||||
|
LogSource::File { path } => {
|
||||||
|
format!("tail -n {} -f {}", tail_lines, shell_escape(path))
|
||||||
|
}
|
||||||
|
LogSource::Docker { container, .. } => {
|
||||||
|
format!(
|
||||||
|
"docker logs -f --tail {} {}",
|
||||||
|
tail_lines,
|
||||||
|
shell_escape(container)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LogSource::Journal { unit } => {
|
||||||
|
format!(
|
||||||
|
"journalctl -u {} -f -n {} --no-pager",
|
||||||
|
shell_escape(unit),
|
||||||
|
tail_lines
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_escape(s: &str) -> String {
|
||||||
|
if s.contains(' ') || s.contains('"') || s.contains('\\') || s.contains('\'') {
|
||||||
|
format!("'{}'", s.replace('\'', "'\"'\"'"))
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_command() {
|
||||||
|
let s = LogSource::File { path: "/var/log/syslog".into() };
|
||||||
|
assert!(build_command(&s, 100).contains("tail -n 100 -f /var/log/syslog"));
|
||||||
|
|
||||||
|
let d = LogSource::Docker { container: "web".into(), image: "nginx".into() };
|
||||||
|
assert!(build_command(&d, 50).contains("docker logs -f --tail 50 web"));
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/main.rs
Normal file
116
src/main.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
mod discovery;
|
||||||
|
mod events;
|
||||||
|
mod log_stream;
|
||||||
|
mod search;
|
||||||
|
mod ssh;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use crossterm::{
|
||||||
|
event::{DisableMouseCapture, EnableMouseCapture, poll, read},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
backend::{Backend, CrosstermBackend},
|
||||||
|
Terminal,
|
||||||
|
};
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::ssh::{SshSession, SshTarget};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "lview")]
|
||||||
|
#[command(about = "TUI for viewing remote logs over SSH")]
|
||||||
|
#[command(version)]
|
||||||
|
struct Args {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum Command {
|
||||||
|
/// Connect via SSH and browse logs.
|
||||||
|
///
|
||||||
|
/// All arguments after `ssh` are passed directly to the system ssh command,
|
||||||
|
/// so your ~/.ssh/config, keys and agent settings are honored.
|
||||||
|
Ssh {
|
||||||
|
/// Arguments passed to ssh (e.g. -p22 root@host).
|
||||||
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||||
|
args: Vec<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
let command = args.command.unwrap_or_else(|| {
|
||||||
|
eprintln!("Usage: lview ssh [ssh-options] [user@]host");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
let Command::Ssh { args: ssh_args } = command;
|
||||||
|
if ssh_args.is_empty() {
|
||||||
|
eprintln!("Error: missing SSH target.");
|
||||||
|
eprintln!("Example: lview ssh -p22 root@192.168.53.3");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Config::load();
|
||||||
|
let target = SshTarget { args: ssh_args };
|
||||||
|
let session = SshSession::new(config.ssh_command.clone(), target);
|
||||||
|
|
||||||
|
let rt = Runtime::new()?;
|
||||||
|
|
||||||
|
// Run discovery before entering TUI
|
||||||
|
let mut app = App::new(session, config);
|
||||||
|
rt.block_on(app.discover());
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let result = run_app(&mut terminal, app, &rt);
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App, rt: &Runtime) -> io::Result<()>
|
||||||
|
where
|
||||||
|
io::Error: From<<B as Backend>::Error>,
|
||||||
|
{
|
||||||
|
let tick_rate = Duration::from_millis(100);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
app.update_stream();
|
||||||
|
|
||||||
|
if app.loading {
|
||||||
|
rt.block_on(app.discover());
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.draw(|f| ui::draw(f, &app))?;
|
||||||
|
|
||||||
|
if poll(tick_rate)? {
|
||||||
|
let event = read()?;
|
||||||
|
if !events::handle_event(&mut app, event) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/search.rs
Normal file
111
src/search.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SearchState {
|
||||||
|
pub query: String,
|
||||||
|
pub is_regex: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SearchState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
query: String::new(),
|
||||||
|
is_regex: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchState {
|
||||||
|
pub fn compile(&self) -> Option<Regex> {
|
||||||
|
if self.query.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let pattern = if self.is_regex {
|
||||||
|
self.query.clone()
|
||||||
|
} else {
|
||||||
|
regex::escape(&self.query)
|
||||||
|
};
|
||||||
|
regex::RegexBuilder::new(&pattern)
|
||||||
|
.case_insensitive(true)
|
||||||
|
.build()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches(&self, line: &str) -> bool {
|
||||||
|
self.compile().map(|re| re.is_match(line)).unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_match(&self, lines: &[String], from: usize) -> Option<usize> {
|
||||||
|
let re = self.compile()?;
|
||||||
|
let mut idx = from + 1;
|
||||||
|
if idx >= lines.len() {
|
||||||
|
idx = 0;
|
||||||
|
}
|
||||||
|
let start = idx;
|
||||||
|
loop {
|
||||||
|
if re.is_match(&lines[idx]) {
|
||||||
|
return Some(idx);
|
||||||
|
}
|
||||||
|
idx = (idx + 1) % lines.len();
|
||||||
|
if idx == start {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_match(&self, lines: &[String], from: usize) -> Option<usize> {
|
||||||
|
let re = self.compile()?;
|
||||||
|
let len = lines.len();
|
||||||
|
if len == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut idx = from.checked_sub(1).unwrap_or(len - 1);
|
||||||
|
let start = idx;
|
||||||
|
loop {
|
||||||
|
if re.is_match(&lines[idx]) {
|
||||||
|
return Some(idx);
|
||||||
|
}
|
||||||
|
if idx == 0 {
|
||||||
|
idx = len - 1;
|
||||||
|
} else {
|
||||||
|
idx -= 1;
|
||||||
|
}
|
||||||
|
if idx == start {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_matches() {
|
||||||
|
let s = SearchState {
|
||||||
|
query: "err".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(s.matches("ERROR: something failed"));
|
||||||
|
assert!(!s.matches("INFO: ok"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_next_prev() {
|
||||||
|
let lines = vec![
|
||||||
|
"INFO ok".into(),
|
||||||
|
"ERROR one".into(),
|
||||||
|
"INFO fine".into(),
|
||||||
|
"ERROR two".into(),
|
||||||
|
];
|
||||||
|
let s = SearchState {
|
||||||
|
query: "ERROR".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(s.next_match(&lines, 0), Some(1));
|
||||||
|
assert_eq!(s.next_match(&lines, 1), Some(3));
|
||||||
|
assert_eq!(s.next_match(&lines, 3), Some(1));
|
||||||
|
assert_eq!(s.prev_match(&lines, 0), Some(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/ssh.rs
Normal file
146
src/ssh.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
use std::io;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
|
||||||
|
use tokio::process::{Child, Command};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SshTarget {
|
||||||
|
pub args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshTarget {
|
||||||
|
pub fn display(&self) -> String {
|
||||||
|
self.args.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort extraction of the host part from ssh args.
|
||||||
|
pub fn host(&self) -> String {
|
||||||
|
let skip_flags: &[&str] = &[
|
||||||
|
"-p", "-P", "-i", "-l", "-F", "-o", "-W", "-D", "-L", "-R", "-J", "-c", "-m", "-Q",
|
||||||
|
"-w", "-b", "-e", "-E",
|
||||||
|
];
|
||||||
|
let mut iter = self.args.iter().peekable();
|
||||||
|
while let Some(arg) = iter.next() {
|
||||||
|
if arg.starts_with('-') {
|
||||||
|
// Flag with inline value (e.g. -p22)
|
||||||
|
let short = arg.get(..2).unwrap_or(arg);
|
||||||
|
if skip_flags.contains(&short) && arg.len() == 2 {
|
||||||
|
iter.next(); // skip separate value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return arg.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.display()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SshSession {
|
||||||
|
pub command: String,
|
||||||
|
pub target: SshTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshSession {
|
||||||
|
pub fn new(command: String, target: SshTarget) -> Self {
|
||||||
|
Self { command, target }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_cmd(&self) -> Command {
|
||||||
|
let mut cmd = Command::new(&self.command);
|
||||||
|
cmd.args(&self.target.args);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exec(&self, remote_command: &str, secs: u64) -> io::Result<Vec<u8>> {
|
||||||
|
let mut cmd = self.base_cmd();
|
||||||
|
cmd.arg("--").arg(remote_command);
|
||||||
|
cmd.stdout(Stdio::piped());
|
||||||
|
cmd.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let mut child = cmd.spawn()?;
|
||||||
|
let dur = Duration::from_secs(secs);
|
||||||
|
let result = timeout(dur, child.wait()).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(status)) => {
|
||||||
|
let mut stdout = child.stdout.take().unwrap();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
stdout.read_to_end(&mut out).await?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(out)
|
||||||
|
} else {
|
||||||
|
let mut stderr = Vec::new();
|
||||||
|
if let Some(mut s) = child.stderr.take() {
|
||||||
|
let _ = s.read_to_end(&mut stderr).await;
|
||||||
|
}
|
||||||
|
Err(io::Error::other(format!(
|
||||||
|
"ssh command failed with status {}: {}",
|
||||||
|
status,
|
||||||
|
String::from_utf8_lossy(&stderr)
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => Err(io::Error::other(e)),
|
||||||
|
Err(_) => {
|
||||||
|
let _ = child.start_kill();
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::TimedOut,
|
||||||
|
format!("ssh command timed out after {}s", secs),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_stream(&self, remote_command: &str) -> io::Result<Child> {
|
||||||
|
let mut cmd = self.base_cmd();
|
||||||
|
cmd.arg("--").arg(remote_command);
|
||||||
|
cmd.stdout(Stdio::piped());
|
||||||
|
cmd.stderr(Stdio::null());
|
||||||
|
cmd.kill_on_drop(true);
|
||||||
|
cmd.spawn()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a remote command and return a channel of lines from stdout.
|
||||||
|
pub fn stream_lines(&self, remote_command: &str) -> io::Result<mpsc::UnboundedReceiver<String>> {
|
||||||
|
let mut child = self.spawn_stream(remote_command)?;
|
||||||
|
let stdout = child.stdout.take().ok_or_else(|| {
|
||||||
|
io::Error::other("failed to capture stdout")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let reader = BufReader::new(stdout);
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if tx.send(line).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = child.wait().await;
|
||||||
|
});
|
||||||
|
Ok(rx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_host_extraction() {
|
||||||
|
let t = SshTarget {
|
||||||
|
args: vec!["-p".into(), "22".into(), "root@192.168.53.3".into()],
|
||||||
|
};
|
||||||
|
assert_eq!(t.host(), "root@192.168.53.3");
|
||||||
|
|
||||||
|
let t2 = SshTarget {
|
||||||
|
args: vec!["-p22".into(), "root@example.com".into()],
|
||||||
|
};
|
||||||
|
assert_eq!(t2.host(), "root@example.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/ui.rs
Normal file
211
src/ui.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Margin},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::{App, LogViewer, Screen};
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, app: &App) {
|
||||||
|
match app.screen {
|
||||||
|
Screen::Picker | Screen::PickerSearch => draw_picker(f, app),
|
||||||
|
Screen::Viewer | Screen::Search => draw_viewer(f, app),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_picker(f: &mut Frame, app: &App) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.margin(1)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(8),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
let top = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
|
||||||
|
.split(chunks[0]);
|
||||||
|
|
||||||
|
draw_log_list(f, app, top[1]);
|
||||||
|
draw_preview(f, app, chunks[1]);
|
||||||
|
draw_status(f, app, chunks[2]);
|
||||||
|
|
||||||
|
if app.screen == Screen::PickerSearch {
|
||||||
|
draw_search_popup(f, app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_viewer(f: &mut Frame, app: &App) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.margin(1)
|
||||||
|
.constraints([Constraint::Min(5), Constraint::Length(1)])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
if let Some(viewer) = &app.viewer {
|
||||||
|
draw_log_buffer(f, viewer, chunks[0]);
|
||||||
|
} else {
|
||||||
|
let p = Paragraph::new("No log selected.").block(default_block(" Viewer "));
|
||||||
|
f.render_widget(p, chunks[0]);
|
||||||
|
}
|
||||||
|
draw_viewer_status(f, app, chunks[1]);
|
||||||
|
|
||||||
|
if app.screen == Screen::Search {
|
||||||
|
draw_search_popup(f, app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_log_list(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.filtered_indices
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &idx)| {
|
||||||
|
let log = &app.logs[idx];
|
||||||
|
let style = if i == app.selected_index {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Blue)
|
||||||
|
.fg(Color::Black)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
ListItem::new(format!("[{}] {}", log.category(), log)).style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title = format!(" Logs ({}/{}) ", app.filtered_indices.len(), app.logs.len());
|
||||||
|
let list = List::new(items).block(default_block(&title));
|
||||||
|
f.render_widget(list, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||||
|
let text = match app.selected_log() {
|
||||||
|
Some(log) => Text::from(vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Source: ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw(log.to_string()),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Category: ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw(log.category()),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
None => Text::from("No log selected."),
|
||||||
|
};
|
||||||
|
let paragraph = Paragraph::new(text)
|
||||||
|
.block(default_block(" Preview "))
|
||||||
|
.wrap(Wrap { trim: true });
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_log_buffer(f: &mut Frame, viewer: &LogViewer, area: ratatui::layout::Rect) {
|
||||||
|
let title = format!(" {} — {} ", viewer.source, if viewer.live { "LIVE" } else { "PAUSED" });
|
||||||
|
let visible = viewer.visible_lines();
|
||||||
|
|
||||||
|
let lines: Vec<Line> = visible
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, line)| {
|
||||||
|
let global_idx = viewer
|
||||||
|
.buffer
|
||||||
|
.iter()
|
||||||
|
.position(|l| std::ptr::eq(l.as_ptr(), line.as_ptr()))
|
||||||
|
.unwrap_or(i);
|
||||||
|
let is_current = global_idx == viewer.scroll;
|
||||||
|
let is_match = viewer
|
||||||
|
.search_cursor
|
||||||
|
.map(|c| c == global_idx)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let mut style = Style::default();
|
||||||
|
if is_current {
|
||||||
|
style = style
|
||||||
|
.bg(Color::DarkGray)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
}
|
||||||
|
if is_match {
|
||||||
|
style = style.bg(Color::Yellow).fg(Color::Black);
|
||||||
|
}
|
||||||
|
Line::from(Span::styled(line.as_str(), style))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(Text::from(lines))
|
||||||
|
.block(default_block(&title))
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||||
|
let help = "j/k: move | Enter: open | /: filter | r: reload | q: quit";
|
||||||
|
let target = app.session.target.host();
|
||||||
|
let text = if app.loading {
|
||||||
|
Text::from("Loading...")
|
||||||
|
} else {
|
||||||
|
match &app.message {
|
||||||
|
Some(msg) => Text::from(format!("{} | {}", target, msg)),
|
||||||
|
None => Text::from(format!("{} | {}", target, help)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let paragraph = Paragraph::new(text)
|
||||||
|
.style(Style::default().fg(Color::Gray))
|
||||||
|
.alignment(Alignment::Left);
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_viewer_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||||
|
let help = "j/k: scroll | g/G: top/bottom | l/Space: live | /: search | n/N: next/prev | s: save | q: back";
|
||||||
|
let text = match (&app.viewer, &app.message) {
|
||||||
|
(Some(v), None) => format!(
|
||||||
|
"{} | lines: {} | scroll: {} | {}",
|
||||||
|
if v.live { "LIVE" } else { "PAUSED" },
|
||||||
|
v.buffer.len(),
|
||||||
|
v.scroll,
|
||||||
|
help
|
||||||
|
),
|
||||||
|
_ => help.to_string(),
|
||||||
|
};
|
||||||
|
let paragraph = Paragraph::new(Text::from(text)).style(Style::default().fg(Color::Gray));
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_search_popup(f: &mut Frame, app: &App) {
|
||||||
|
let area = f.area();
|
||||||
|
let popup_area = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(30),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Percentage(70),
|
||||||
|
])
|
||||||
|
.split(area)[1];
|
||||||
|
let popup_area = popup_area.inner(Margin {
|
||||||
|
horizontal: area.width / 4,
|
||||||
|
vertical: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let query = app.current_filter_query();
|
||||||
|
let title = if app.screen == Screen::PickerSearch {
|
||||||
|
" Filter logs "
|
||||||
|
} else {
|
||||||
|
" Search in log "
|
||||||
|
};
|
||||||
|
let input = Paragraph::new(query)
|
||||||
|
.block(default_block(title).border_style(Style::default().fg(Color::Yellow)))
|
||||||
|
.wrap(Wrap { trim: true });
|
||||||
|
f.render_widget(Clear, popup_area);
|
||||||
|
f.render_widget(input, popup_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_block(title: &str) -> Block<'_> {
|
||||||
|
Block::default()
|
||||||
|
.title(title)
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue