Show job log inline below running tasks table

View Log now displays the log in a RichLog panel below the buttons
instead of opening a modal screen. The log tails the file in real-time
with a 0.3s poll interval while the job is running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 21:47:49 +02:00
parent 83ccf44117
commit fdf2f0b10a
2 changed files with 71 additions and 18 deletions

View File

@@ -278,7 +278,8 @@ SelectionList {
} }
/* Log viewer */ /* Log viewer */
#log-viewer { #log-viewer,
#rt-log-viewer {
height: 1fr; height: 1fr;
min-height: 8; min-height: 8;
border: round $accent; border: round $accent;

View File

@@ -1,12 +1,14 @@
from datetime import datetime from datetime import datetime
from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, DataTable from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog
from textual.containers import Vertical, Horizontal from textual.containers import Vertical, Horizontal
from textual.timer import Timer
from tui.jobs import job_manager from tui.jobs import job_manager
from tui.widgets import ConfirmDialog, OperationLog from tui.widgets import ConfirmDialog
class RunningTasksScreen(Screen): class RunningTasksScreen(Screen):
@@ -23,6 +25,7 @@ class RunningTasksScreen(Screen):
yield Button("Kill Job", variant="error", id="btn-rt-kill") yield Button("Kill Job", variant="error", id="btn-rt-kill")
yield Button("Clear Finished", variant="warning", id="btn-rt-clear") yield Button("Clear Finished", variant="warning", id="btn-rt-clear")
yield Button("Back", id="btn-rt-back") yield Button("Back", id="btn-rt-back")
yield RichLog(id="rt-log-viewer", wrap=True, highlight=True)
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
@@ -31,6 +34,9 @@ class RunningTasksScreen(Screen):
table.cursor_type = "row" table.cursor_type = "row"
self._refresh_table() self._refresh_table()
self._timer = self.set_interval(1, self._refresh_table) self._timer = self.set_interval(1, self._refresh_table)
self._log_timer: Timer | None = None
self._viewing_job_id: str | None = None
self._log_file_pos: int = 0
def _format_duration(self, job) -> str: def _format_duration(self, job) -> str:
end = job.finished_at or datetime.now() end = job.finished_at or datetime.now()
@@ -47,7 +53,6 @@ class RunningTasksScreen(Screen):
def _refresh_table(self) -> None: def _refresh_table(self) -> None:
job_manager.check_reconnected() job_manager.check_reconnected()
table = self.query_one("#rt-table", DataTable) table = self.query_one("#rt-table", DataTable)
# Preserve cursor position
old_row = table.cursor_coordinate.row if table.row_count > 0 else 0 old_row = table.cursor_coordinate.row if table.row_count > 0 else 0
table.clear() table.clear()
for job in job_manager.list_jobs(): for job in job_manager.list_jobs():
@@ -73,20 +78,67 @@ class RunningTasksScreen(Screen):
elif event.button.id == "btn-rt-kill": elif event.button.id == "btn-rt-kill":
self._kill_selected() self._kill_selected()
elif event.button.id == "btn-rt-view": elif event.button.id == "btn-rt-view":
table = self.query_one("#rt-table", DataTable) self._view_selected_log()
if table.row_count == 0:
self.notify("No jobs to view", severity="warning") def _view_selected_log(self) -> None:
return table = self.query_one("#rt-table", DataTable)
row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate) if table.row_count == 0:
job_id = str(row_key) self.notify("No jobs to view", severity="warning")
job = job_manager.get_job(job_id) return
if job: row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
log_screen = OperationLog( job_id = str(row_key)
title=job.label, job = job_manager.get_job(job_id)
show_spinner=job.status == "running", if not job:
job_id=job.id, self.notify("Job not found", severity="warning")
) return
self.app.push_screen(log_screen) log_viewer = self.query_one("#rt-log-viewer", RichLog)
log_viewer.clear()
self._viewing_job_id = job_id
self._log_file_pos = 0
# Load existing content from log file
if job._log_file and Path(job._log_file).is_file():
try:
content = Path(job._log_file).read_text()
self._log_file_pos = len(content.encode())
for line in content.splitlines():
log_viewer.write(line)
except OSError:
pass
elif job.output:
for line in job.output:
log_viewer.write(line)
# Start polling for new content if job is running
if self._log_timer:
self._log_timer.stop()
if job.status == "running":
self._log_timer = self.set_interval(0.3, self._poll_log)
def _poll_log(self) -> None:
if not self._viewing_job_id:
return
job = job_manager.get_job(self._viewing_job_id)
if not job:
if self._log_timer:
self._log_timer.stop()
return
try:
log_viewer = self.query_one("#rt-log-viewer", RichLog)
except Exception:
return
if job._log_file and Path(job._log_file).is_file():
try:
with open(job._log_file, "r") as f:
f.seek(self._log_file_pos)
new_data = f.read()
if new_data:
self._log_file_pos += len(new_data.encode())
for line in new_data.splitlines():
log_viewer.write(line)
except OSError:
pass
if job.status != "running":
if self._log_timer:
self._log_timer.stop()
def _kill_selected(self) -> None: def _kill_selected(self) -> None:
table = self.query_one("#rt-table", DataTable) table = self.query_one("#rt-table", DataTable)