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:
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user