Compare commits

..

29 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
7 changed files with 312 additions and 68 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

View file

@ -5,8 +5,8 @@ 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"]
@ -22,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"],
]

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.1.tar.gz"
sha256 "ec83436ce29602df7cfbccb554a0e4ed41e3a90776ea9117de91e0e8a8638248"
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"

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

@ -46,8 +46,13 @@ impl LogViewer {
}
}
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) {
@ -130,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,
@ -143,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,
@ -232,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;
@ -355,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

@ -74,9 +74,10 @@ 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()?;

105
src/ui.rs
View file

@ -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]);
@ -106,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();
@ -133,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);
}
@ -162,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);
}
@ -210,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
}
}