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