Compare commits

..

No commits in common. "master" and "v0.1.2" have entirely different histories.

7 changed files with 68 additions and 312 deletions

View file

@ -1,165 +0,0 @@
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

View file

@ -5,8 +5,8 @@ edition = "2024"
authors = ["janvete"]
description = "TUI for viewing remote logs over SSH"
license = "MIT"
repository = "https://git.janvete.com/seth/lview"
homepage = "https://git.janvete.com/seth/lview"
repository = "https://github.com/janvete/lview"
homepage = "https://github.com/janvete/lview"
keywords = ["cli", "tui", "ssh", "logs", "docker"]
categories = ["command-line-utilities"]
@ -22,14 +22,3 @@ 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"],
]

View file

@ -1,8 +1,8 @@
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"
url "https://github.com/janvete/lview/archive/refs/tags/v0.1.1.tar.gz"
sha256 "ec83436ce29602df7cfbccb554a0e4ed41e3a90776ea9117de91e0e8a8638248"
license "MIT"
head "https://github.com/janvete/lview.git", branch: "master"

View file

@ -1,66 +1,58 @@
# lview
A TUI for viewing logs on remote Linux machines over SSH. It automatically detects system logs, Docker containers, `/opt` applications, and the systemd journal.
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.
## Installation
## Instalace
### macOS (Homebrew)
```bash
brew tap janvete/tools https://git.janvete.com/seth/homebrew-tools.git
brew tap janvete/tools
brew install lview
```
### Debian / Ubuntu (APT)
### Ze zdroje
```bash
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
cargo install --git https://github.com/janvete/lview
```
### From source
```bash
cargo install --git ssh://git@git-ssh.janvete.com:554/seth/lview
```
## Usage
## Použití
```bash
lview ssh -p22 root@192.168.53.3
```
All arguments after `ssh` are passed directly to the system `ssh` command, so your `~/.ssh/config`, SSH agent, and keys are used automatically.
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.
## Controls
## Ovládání
### Log picker
### Výběr logu
| 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 |
| 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 |
### Log viewer
### Prohlížeč logu
| 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 |
| 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 |
## Configuration
## Konfigurace
Create `~/.config/lview/config.toml`:
Vytvořte `~/.config/lview/config.toml`:
```toml
ssh_command = "ssh"

View file

@ -46,13 +46,8 @@ impl LogViewer {
}
}
/// 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 visible_lines(&self) -> Vec<&String> {
self.buffer.iter().filter(|l| self.search.matches(l)).collect()
}
pub fn push_line(&mut self, line: String) {
@ -135,7 +130,6 @@ 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,
@ -149,11 +143,10 @@ pub struct App {
}
impl App {
pub fn new(session: SshSession, config: Config, runtime: tokio::runtime::Handle) -> Self {
pub fn new(session: SshSession, config: Config) -> Self {
Self {
session,
config,
runtime,
logs: Vec::new(),
filtered_indices: Vec::new(),
selected_index: 0,
@ -239,11 +232,7 @@ impl App {
let Some(source) = self.selected_log().cloned() else {
return Ok(());
};
let session = self.session.clone();
let stream_source = source.clone();
let rx = self.runtime.block_on(async move {
start_stream(&session, &stream_source, 1000)
})?;
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;
@ -366,8 +355,7 @@ mod tests {
#[test]
fn test_app_filter() {
let session = SshSession::new("ssh".into(), crate::ssh::SshTarget { args: vec!["host".into()] });
let rt = tokio::runtime::Runtime::new().unwrap();
let mut app = App::new(session, Config::default(), rt.handle().clone());
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() },

View file

@ -74,10 +74,9 @@ 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, runtime_handle);
let mut app = App::new(session, config);
rt.block_on(app.discover());
enable_raw_mode()?;

103
src/ui.rs
View file

@ -26,7 +26,12 @@ fn draw_picker(f: &mut Frame, app: &App) {
])
.split(f.area());
draw_log_list(f, app, chunks[0]);
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]);
@ -101,34 +106,22 @@ 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 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 title = format!(" {} — {} ", viewer.source, if viewer.live { "LIVE" } else { "PAUSED" });
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()
.map(|(global_idx, line)| {
let is_current = *global_idx == viewer.scroll;
.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)
.map(|c| c == global_idx)
.unwrap_or(false);
let mut style = Style::default();
@ -140,23 +133,13 @@ fn draw_log_buffer(f: &mut Frame, viewer: &LogViewer, area: ratatui::layout::Rec
if is_match {
style = style.bg(Color::Yellow).fg(Color::Black);
}
let dot_color = log_level_color(line);
Line::from(vec![
Span::styled("● ", Style::default().fg(dot_color)),
Span::styled(line.as_str(), style),
])
Line::from(Span::styled(line.as_str(), style))
})
.collect();
let paragraph = Paragraph::new(Text::from(lines))
.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));
.block(default_block(&title))
.wrap(Wrap { trim: false });
f.render_widget(paragraph, area);
}
@ -179,24 +162,17 @@ 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 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)
};
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),
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
),
])
}
_ => Line::from(Span::styled(help, Style::default().fg(Color::Gray))),
_ => help.to_string(),
};
let paragraph = Paragraph::new(Text::from(lines));
let paragraph = Paragraph::new(Text::from(text)).style(Style::default().fg(Color::Gray));
f.render_widget(paragraph, area);
}
@ -234,26 +210,3 @@ 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
}
}