Compare commits

...

33 commits

Author SHA1 Message Date
SW
6c96818e97 ci: remove corrupted .deb files before dpkg-scanpackages
All checks were successful
Release / release (push) Successful in 54s
2026-09-10 15:43:18 +02:00
SW
ef49632ef0 ci: build .deb manually with dpkg-deb instead of cargo-deb
Some checks failed
Release / release (push) Failing after 53s
2026-09-10 15:38:09 +02:00
SW
62c4c7c6cb ci: use cargo deb without --no-build, add .deb debug output
Some checks failed
Release / release (push) Failing after 1m31s
2026-09-10 15:29:15 +02:00
SW
dfd7b856a4 ci: drop macOS build and Homebrew tap, keep Linux + .deb + APT only
Some checks failed
Release / release (push) Failing after 1m34s
2026-09-10 15:23:10 +02:00
SW
b80043b5da ci: make macOS build optional, skip Homebrew tap if it fails
Some checks failed
Release / release (push) Failing after 3m4s
2026-09-10 15:14:56 +02:00
SW
0e9b73d396 ci: install zig into /usr/local/bin with symlink
Some checks failed
Release / release (push) Failing after 3m2s
2026-09-10 15:01:06 +02:00
SW
3349ff8ba0 ci: download zig binary directly instead of apt
Some checks failed
Release / release (push) Failing after 48s
2026-09-10 14:58:44 +02:00
SW
e78e71c395 ci: install zig for cargo-zigbuild macOS cross-compile
Some checks failed
Release / release (push) Failing after 3s
2026-09-10 14:55:58 +02:00
SW
ed129e8cc6 ci: fix SSH deploy key mount path for Forgejo runner
Some checks failed
Release / release (push) Has been cancelled
2026-09-10 14:51:48 +02:00
SW
732577da4c ci: switch release workflow to Forgejo, update repo URLs
Some checks failed
Release / release (push) Failing after 43s
- Add Forgejo release workflow with .deb, Homebrew tap and APT updates.
- Remove obsolete GitHub Actions release workflow.
- Update Cargo.toml repository and homepage URLs to git.janvete.com.
2026-09-10 14:37:48 +02:00
SW
445e40ba27 docs: update install instructions for Forgejo 2026-09-10 14:22:13 +02:00
SW
31dc7d189b docs: translate README to English 2026-06-18 21:34:46 +02:00
SW
58523a2ec8 Update formula to 0.1.7 2026-06-18 09:20:04 +02:00
SW
640db18d25 Color LIVE green and PAUSED red in viewer title and status 2026-06-18 09:19:34 +02:00
SW
643590086c Fix artifact subfolder paths in release upload 2026-06-18 09:10:11 +02:00
SW
0a19875717 Fix artifact download paths in release workflow 2026-06-18 09:07:15 +02:00
SW
9d001823d5 Fix artifact paths in release workflow 2026-06-18 09:04:45 +02:00
SW
da3e723a21 Fix release workflow inputs and overwrite 2026-06-18 09:02:17 +02:00
SW
aefaffbcbf Allow release action to update existing release 2026-06-18 08:59:52 +02:00
SW
ae6639ed2d Allow manual workflow dispatch 2026-06-18 08:57:16 +02:00
SW
b2c86c00f3 Update formula to 0.1.6 2026-06-18 08:56:10 +02:00
SW
28e9f8f254 Add Debian/Ubuntu .deb package and release workflow 2026-06-18 08:55:23 +02:00
SW
cdce151f77 Update formula to 0.1.5 2026-06-18 08:47:28 +02:00
SW
7e4be4e1af Stretch picker to full width and add colored log-level dots 2026-06-18 08:47:03 +02:00
SW
e4182d32fa Update formula to 0.1.4 2026-06-17 22:12:24 +02:00
SW
525acb9eb4 Fix viewer scrolling so current line stays visible 2026-06-17 22:09:20 +02:00
SW
9996616d9b Update formula to 0.1.3 2026-06-17 21:56:28 +02:00
SW
c7049d5eb2 Fix Tokio runtime panic when opening log stream 2026-06-17 21:56:02 +02:00
SW
100397f028 Update formula to 0.1.2 2026-06-17 21:51:32 +02:00
SW
4b7389cfe7 Disable mouse capture, improve SSH error handling, add log labels 2026-06-17 21:51:08 +02:00
SW
bafce8e49c Update formula to 0.1.1 2026-06-17 21:42:47 +02:00
SW
d58847effa Fix picker scrolling and SSH host-key prompt 2026-06-17 21:42:23 +02:00
SW
48824dc681 Add Homebrew formula 2026-06-17 21:34:42 +02:00
10 changed files with 488 additions and 84 deletions

View file

@ -0,0 +1,165 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
container:
image: rust:bookworm
volumes:
- /opt/runner/keys:/keys:ro
steps:
- name: Install tools
run: |
apt-get update
apt-get install -y dpkg-dev git curl openssh-client
- name: Setup SSH
run: |
test -r /keys/forgejo_runner || { echo "Deploy key /keys/forgejo_runner chybi"; exit 1; }
install -m 600 /keys/forgejo_runner /tmp/forgejo_runner
echo "GIT_SSH_COMMAND=ssh -p 554 -i /tmp/forgejo_runner -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" >> "$GITHUB_ENV"
- name: Checkout source
run: |
git clone ssh://git@git-ssh.janvete.com:554/seth/lview.git src
cd src
git checkout "${{ github.ref_name }}"
- name: Build Linux release binary and .deb
working-directory: src
run: |
VERSION=${{ github.ref_name }}
VER=${VERSION#v}
mkdir -p dist
cargo build --release
# .deb package
DEB_DIR="lview_${VER}_amd64"
mkdir -p "${DEB_DIR}/DEBIAN" "${DEB_DIR}/usr/bin"
cp target/release/lview "${DEB_DIR}/usr/bin/lview"
chmod 755 "${DEB_DIR}/usr/bin/lview"
cat > "${DEB_DIR}/DEBIAN/control" << CONTROL
Package: lview
Version: ${VER}
Section: utils
Priority: optional
Architecture: amd64
Maintainer: janvete <jan.vete@example.com>
Description: TUI for viewing remote logs over SSH
CONTROL
dpkg-deb --build "${DEB_DIR}" "dist/${DEB_DIR}.deb"
# Linux tarball
STAGING=$(mktemp -d)
cp target/release/lview "${STAGING}/lview"
tar -czf "dist/lview-${VERSION}-linux-amd64.tar.gz" -C "${STAGING}" lview
rm -rf "${STAGING}"
- name: Print checksums
working-directory: src
run: |
echo "=== SHA256 checksums ==="
sha256sum dist/*
- name: Create Forgejo Release
working-directory: src
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: https://git.janvete.com
run: |
VERSION=${{ github.ref_name }}
API="${FORGEJO_URL}/api/v1/repos/seth/lview"
RELEASE_JSON=$(curl -sS -H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"tag_name\":\"${VERSION}\",\"name\":\"${VERSION}\",\"draft\":false,\"prerelease\":false}" \
"${API}/releases")
echo "Create release response: ${RELEASE_JSON}"
RELEASE_ID=$(echo "${RELEASE_JSON}" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "${RELEASE_ID}" ]; then
echo "Release may already exist, fetching existing release..."
EXISTING=$(curl -sS -H "Authorization: token ${FORGEJO_TOKEN}" \
"${API}/releases/tags/${VERSION}")
echo "Existing release response: ${EXISTING}"
RELEASE_ID=$(echo "${EXISTING}" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
fi
if [ -z "${RELEASE_ID}" ]; then
echo "Failed to create or fetch release"
exit 1
fi
for ARCHIVE in dist/*; do
[ -e "${ARCHIVE}" ] || continue
NAME=$(basename "${ARCHIVE}")
case "${NAME}" in
*.tar.gz) CONTENT_TYPE="application/gzip" ;;
*.deb) CONTENT_TYPE="application/vnd.debian.binary-package" ;;
*) CONTENT_TYPE="application/octet-stream" ;;
esac
curl -sS -H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: ${CONTENT_TYPE}" \
--data-binary "@${ARCHIVE}" \
"${API}/releases/${RELEASE_ID}/assets?name=${NAME}"
echo "Uploaded ${NAME}"
done
- name: Update APT repository
working-directory: src
run: |
VERSION=${{ github.ref_name }}
git clone ssh://git@git-ssh.janvete.com:554/seth/apt.git apt-repo
cp dist/*.deb apt-repo/
cd apt-repo
# Remove any corrupted .deb files left from previous failed runs
for f in *.deb; do
[ -e "$f" ] || continue
if ! dpkg-deb -I "$f" >/dev/null 2>&1; then
echo "Removing corrupted package: $f"
rm -f "$f"
fi
done
dpkg-scanpackages . > Packages
gzip -k -f Packages
cat > Release << RELEASE
Origin: janvete
Label: janvete
Suite: stable
Codename: stable
Version: 1.0
Architectures: amd64
Components: main
Description: janvete tools APT repository
Date: $(date -Ru)
RELEASE
{
echo "SHA256:"
for f in Packages Packages.gz *.deb; do
[ -e "$f" ] || continue
printf " %s %16d %s\n" "$(sha256sum "$f" | cut -d' ' -f1)" "$(stat -c%s "$f")" "$f"
done
} >> Release
git config user.email "forgejo-actions[bot]@janvete.com"
git config user.name "forgejo-actions[bot]"
git add -A
git commit -m "chore: add lview ${VERSION}"
git push

64
Cargo.lock generated
View file

@ -148,6 +148,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@ -353,6 +362,17 @@ dependencies = [
"phf",
]
[[package]]
name = "ctrlc"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix 0.31.3",
"windows-sys",
]
[[package]]
name = "darling"
version = "0.23.0"
@ -452,6 +472,18 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
]
[[package]]
name = "document-features"
version = "0.2.12"
@ -850,6 +882,7 @@ dependencies = [
"chrono",
"clap",
"crossterm",
"ctrlc",
"directories",
"fuzzy-matcher",
"ratatui",
@ -865,7 +898,7 @@ version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [
"nix",
"nix 0.29.0",
"winapi",
]
@ -921,6 +954,18 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
@ -966,6 +1011,21 @@ dependencies = [
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "once_cell"
version = "1.21.4"
@ -1582,7 +1642,7 @@ dependencies = [
"libc",
"log",
"memmem",
"nix",
"nix 0.29.0",
"num-derive",
"num-traits",
"ordered-float",

View file

@ -5,14 +5,15 @@ 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"
repository = "https://git.janvete.com/seth/lview"
homepage = "https://git.janvete.com/seth/lview"
keywords = ["cli", "tui", "ssh", "logs", "docker"]
categories = ["command-line-utilities"]
[dependencies]
chrono = "0.4.45"
clap = { version = "4.6.1", features = ["derive"] }
ctrlc = "3.4.6"
crossterm = "0.29.0"
directories = "6.0.0"
fuzzy-matcher = "0.3.7"
@ -21,3 +22,14 @@ 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"
[package.metadata.deb]
maintainer = "janvete <jan.vete@example.com>"
copyright = "2026, janvete"
extended-description = "TUI application for viewing remote logs over SSH."
section = "utils"
priority = "optional"
assets = [
["target/release/lview", "usr/bin/lview", "755"],
["README.md", "usr/share/doc/lview/README.md", "644"],
]

18
Formula/lview.rb Normal file
View file

@ -0,0 +1,18 @@
class Lview < Formula
desc "TUI for viewing remote logs over SSH"
homepage "https://github.com/janvete/lview"
url "https://github.com/janvete/lview/archive/refs/tags/v0.1.7.tar.gz"
sha256 "7bb92852698486571a5c31158f64b398d123d3c7ebb1982733c43e0e1c84209a"
license "MIT"
head "https://github.com/janvete/lview.git", branch: "master"
depends_on "rust" => :build
def install
system "cargo", "install", *std_cargo_args
end
test do
assert_match "TUI for viewing remote logs over SSH", shell_output("#{bin}/lview --help")
end
end

View file

@ -1,58 +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.
A TUI for viewing logs on remote Linux machines over SSH. It automatically detects system logs, Docker containers, `/opt` applications, and the systemd journal.
## Instalace
## Installation
### macOS (Homebrew)
```bash
brew tap janvete/tools
brew tap janvete/tools https://git.janvete.com/seth/homebrew-tools.git
brew install lview
```
### Ze zdroje
### Debian / Ubuntu (APT)
```bash
cargo install --git https://github.com/janvete/lview
echo "deb [trusted=yes] https://git.janvete.com/seth/apt/raw/branch/main ./" | sudo tee /etc/apt/sources.list.d/janvete.list
sudo apt update
sudo apt install lview
```
## Použití
### From source
```bash
cargo install --git ssh://git@git-ssh.janvete.com:554/seth/lview
```
## Usage
```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.
All arguments after `ssh` are passed directly to the system `ssh` command, so your `~/.ssh/config`, SSH agent, and keys are used automatically.
## Ovládání
## Controls
### Výběr logu
### Log picker
| 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 |
| Key | Action |
|-----|--------|
| `j` / `k` or `↓` / `↑` | move through the list |
| `Enter` | open the selected log |
| `/` | fuzzy filter the log list |
| `r` | reload the log list |
| `q` | quit |
### Prohlížeč logu
### Log viewer
| 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 |
| Key | Action |
|-----|--------|
| `j` / `k` or `↓` / `↑` | scroll |
| `Ctrl+d` / `Ctrl+u` | scroll by 10 lines |
| `g` / `G` | jump to top / bottom |
| `l` or `space` | toggle live preview |
| `/` | search in the log (regex, case-insensitive) |
| `n` / `N` | next / previous match |
| `s` | save the current buffer to `/tmp` |
| `q` or `Esc` | back to the picker |
## Konfigurace
## Configuration
Vytvořte `~/.config/lview/config.toml`:
Create `~/.config/lview/config.toml`:
```toml
ssh_command = "ssh"

View file

@ -28,6 +28,7 @@ pub struct LogViewer {
pub max_lines: usize,
pub search: SearchState,
pub search_cursor: Option<usize>,
pub error: Option<String>,
}
impl LogViewer {
@ -41,14 +42,24 @@ impl LogViewer {
max_lines,
search: SearchState::default(),
search_cursor: None,
error: None,
}
}
pub fn visible_lines(&self) -> Vec<&String> {
self.buffer.iter().filter(|l| self.search.matches(l)).collect()
/// Returns visible lines together with their original buffer index.
pub fn visible_lines(&self) -> Vec<(usize, &String)> {
self.buffer
.iter()
.enumerate()
.filter(|(_, l)| self.search.matches(l))
.collect()
}
pub fn push_line(&mut self, line: String) {
if let Some(err) = line.strip_prefix("STDERR:") {
self.error = Some(err.to_string());
return;
}
if self.buffer.len() >= self.max_lines {
self.buffer.pop_front();
if self.scroll > 0 {
@ -124,6 +135,7 @@ impl LogViewer {
pub struct App {
pub session: SshSession,
pub config: Config,
pub runtime: tokio::runtime::Handle,
pub logs: Vec<LogSource>,
pub filtered_indices: Vec<usize>,
pub selected_index: usize,
@ -137,10 +149,11 @@ pub struct App {
}
impl App {
pub fn new(session: SshSession, config: Config) -> Self {
pub fn new(session: SshSession, config: Config, runtime: tokio::runtime::Handle) -> Self {
Self {
session,
config,
runtime,
logs: Vec::new(),
filtered_indices: Vec::new(),
selected_index: 0,
@ -189,7 +202,7 @@ impl App {
.enumerate()
.filter_map(|(i, log)| {
self.matcher
.fuzzy_match(&log.to_string(), &self.picker_filter)
.fuzzy_match(&log.search_text(), &self.picker_filter)
.map(|score| (score, i))
})
.collect();
@ -226,7 +239,11 @@ impl App {
let Some(source) = self.selected_log().cloned() else {
return Ok(());
};
let rx = start_stream(&self.session, &source, 1000)?;
let session = self.session.clone();
let stream_source = source.clone();
let rx = self.runtime.block_on(async move {
start_stream(&session, &stream_source, 1000)
})?;
self.viewer = Some(LogViewer::new(source, rx, self.config.max_log_lines));
self.screen = Screen::Viewer;
self.message = None;
@ -244,6 +261,9 @@ impl App {
while let Ok(line) = viewer.receiver.try_recv() {
viewer.push_line(line);
}
if let Some(err) = viewer.error.take() {
self.message = Some(format!("SSH error: {}", err));
}
}
}
@ -346,7 +366,8 @@ mod tests {
#[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());
let rt = tokio::runtime::Runtime::new().unwrap();
let mut app = App::new(session, Config::default(), rt.handle().clone());
app.logs = vec![
LogSource::File { path: "/var/log/syslog".into() },
LogSource::File { path: "/opt/app/app.log".into() },

View file

@ -36,8 +36,36 @@ impl LogSource {
}
}
/// Human-readable label shown in the log picker.
pub fn display_name(&self) -> String {
match self {
LogSource::File { path } => {
let basename = std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.clone());
if let Some(parent) = std::path::Path::new(path).parent() {
let parent_str = parent.to_string_lossy();
if parent_str == "/var/log" {
return format!("{} (system)", basename);
} else if parent_str.starts_with("/var/log/") || parent_str.starts_with("/opt/") {
return format!("{} ({})", basename, parent_str);
}
}
format!("{} ({})", basename, path)
}
LogSource::Docker { container, image } => format!("{} (docker: {})", container, image),
LogSource::Journal { unit } => format!("{} (journal)", unit),
}
}
/// Text used for fuzzy searching (label + raw source).
pub fn search_text(&self) -> String {
format!("{} {}", self.display_name(), self)
}
pub fn sort_key(&self) -> String {
format!("{}:{}", self.category(), self)
format!("{}:{}", self.category(), self.display_name())
}
}

View file

@ -8,11 +8,12 @@ mod ssh;
mod ui;
use std::io;
use std::sync::Arc;
use std::time::Duration;
use clap::{Parser, Subcommand};
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture, poll, read},
event::{poll, read},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
@ -48,6 +49,12 @@ enum Command {
},
}
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
}
fn main() -> io::Result<()> {
let args = Args::parse();
let command = args.command.unwrap_or_else(|| {
@ -67,37 +74,51 @@ fn main() -> io::Result<()> {
let session = SshSession::new(config.ssh_command.clone(), target);
let rt = Runtime::new()?;
let runtime_handle = rt.handle().clone();
// Run discovery before entering TUI
let mut app = App::new(session, config);
let mut app = App::new(session, config, runtime_handle);
rt.block_on(app.discover());
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_app(&mut terminal, app, &rt);
// Ensure terminal is restored on panic or Ctrl-C
std::panic::set_hook(Box::new(|info| {
restore_terminal();
eprintln!("Panic: {}", info);
}));
let running = Arc::new(std::sync::atomic::AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, std::sync::atomic::Ordering::SeqCst);
})
.expect("failed to set Ctrl-C handler");
let result = run_app(&mut terminal, app, &rt, running);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App, rt: &Runtime) -> io::Result<()>
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut app: App,
rt: &Runtime,
running: Arc<std::sync::atomic::AtomicBool>,
) -> io::Result<()>
where
io::Error: From<<B as Backend>::Error>,
{
let tick_rate = Duration::from_millis(100);
loop {
while running.load(std::sync::atomic::Ordering::SeqCst) {
app.update_stream();
if app.loading {
@ -113,4 +134,5 @@ where
}
}
}
Ok(())
}

View file

@ -52,7 +52,13 @@ impl SshSession {
fn base_cmd(&self) -> Command {
let mut cmd = Command::new(&self.command);
// Avoid interactive prompts from ssh itself. Passphrase prompts from the
// SSH agent may still appear in the terminal, but password prompts are
// disabled and unknown host keys are accepted automatically.
cmd.arg("-o").arg("PasswordAuthentication=no");
cmd.arg("-o").arg("StrictHostKeyChecking=accept-new");
cmd.args(&self.target.args);
cmd.stdin(Stdio::null());
cmd
}
@ -100,20 +106,36 @@ impl SshSession {
let mut cmd = self.base_cmd();
cmd.arg("--").arg(remote_command);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::null());
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
cmd.spawn()
}
/// Spawn a remote command and return a channel of lines from stdout.
/// Lines coming from stderr are prefixed with `STDERR:` so the UI can
/// surface ssh errors without mixing them into the log output.
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 stderr = child.stderr.take().ok_or_else(|| {
io::Error::other("failed to capture stderr")
})?;
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
let tx_err = tx.clone();
tokio::spawn(async move {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if tx_err.send(format!("STDERR:{}", line)).is_err() {
break;
}
}
});
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {

112
src/ui.rs
View file

@ -2,7 +2,7 @@ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
@ -26,12 +26,7 @@ fn draw_picker(f: &mut Frame, app: &App) {
])
.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_log_list(f, app, chunks[0]);
draw_preview(f, app, chunks[1]);
draw_status(f, app, chunks[2]);
@ -75,13 +70,14 @@ fn draw_log_list(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
} else {
Style::default()
};
ListItem::new(format!("[{}] {}", log.category(), log)).style(style)
ListItem::new(format!("[{}] {}", log.category(), log.display_name())).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);
let mut state = ListState::default().with_selected(Some(app.selected_index));
f.render_stateful_widget(list, area, &mut state);
}
fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
@ -105,22 +101,34 @@ fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
}
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 live_style = if viewer.live {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
};
let title = Line::from(vec![
Span::raw(format!(" {} — ", viewer.source)),
Span::styled(if viewer.live { "LIVE" } else { "PAUSED" }, live_style),
Span::raw(" "),
]);
let visible = viewer.visible_lines();
// Compute the viewport offset so the current line stays in view.
let current_visible_idx = visible
.iter()
.position(|(i, _)| *i == viewer.scroll)
.unwrap_or(visible.len().saturating_sub(1));
let scroll_offset = current_visible_idx
.saturating_sub(area.height as usize / 2)
.min(visible.len().saturating_sub(1));
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;
.map(|(global_idx, line)| {
let is_current = *global_idx == viewer.scroll;
let is_match = viewer
.search_cursor
.map(|c| c == global_idx)
.map(|c| c == *global_idx)
.unwrap_or(false);
let mut style = Style::default();
@ -132,13 +140,23 @@ fn draw_log_buffer(f: &mut Frame, viewer: &LogViewer, area: ratatui::layout::Rec
if is_match {
style = style.bg(Color::Yellow).fg(Color::Black);
}
Line::from(Span::styled(line.as_str(), style))
let dot_color = log_level_color(line);
Line::from(vec![
Span::styled("● ", Style::default().fg(dot_color)),
Span::styled(line.as_str(), style),
])
})
.collect();
let paragraph = Paragraph::new(Text::from(lines))
.block(default_block(&title))
.wrap(Wrap { trim: false });
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.wrap(Wrap { trim: false })
.scroll((scroll_offset as u16, 0));
f.render_widget(paragraph, area);
}
@ -161,17 +179,24 @@ fn draw_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
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 lines = match (&app.viewer, &app.message) {
(Some(v), None) => {
let live_style = if v.live {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
};
let paragraph = Paragraph::new(Text::from(text)).style(Style::default().fg(Color::Gray));
Line::from(vec![
Span::styled(if v.live { "LIVE" } else { "PAUSED" }, live_style),
Span::styled(
format!(" | lines: {} | scroll: {} | {}", v.buffer.len(), v.scroll, help),
Style::default().fg(Color::Gray),
),
])
}
_ => Line::from(Span::styled(help, Style::default().fg(Color::Gray))),
};
let paragraph = Paragraph::new(Text::from(lines));
f.render_widget(paragraph, area);
}
@ -209,3 +234,26 @@ fn default_block(title: &str) -> Block<'_> {
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
}
/// Determine a color for the log-level indicator dot based on the line content.
fn log_level_color(line: &str) -> Color {
let lower = line.to_lowercase();
if lower.contains("error")
|| lower.contains("critical")
|| lower.contains("fatal")
|| lower.contains("panic")
|| lower.contains("emergency")
{
Color::Red
} else if lower.contains("warn") || lower.contains("warning") {
Color::Yellow
} else if lower.contains("info") || lower.contains("notice") {
Color::Cyan
} else if lower.contains("debug") {
Color::Gray
} else if lower.contains("trace") {
Color::DarkGray
} else {
Color::White
}
}