Initial lview implementation

This commit is contained in:
SW 2026-06-17 21:33:03 +02:00
commit 533c5ae637
13 changed files with 3526 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2055
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

23
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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))
}