Add the task log: recent tasks with status, Enter shows the log
CI / pytest and ruff (pull_request) Successful in 4m10s
CI / pytest and ruff (push) Successful in 4m32s

l on the main screen opens "Tasks", the recent cluster tasks newest
first. l on the info screen shows only that guest's tasks. Columns:
start, duration (or "running"), node, type, VMID ("-" for node
tasks), user and status. A failed task gets a "!" in the leading
column. Enter opens the task's log in a plain-text widget, so the
output is never parsed as markup.

`task list -o json` has no start or end times, so the list comes from
`/cluster/tasks`. The upid is validated (it must start with "UPID:")
before it goes in as a positional argument.

Review fixes:
- A finished task without a start time no longer crashes the app; its
  duration shows "-".
- "running" now means there is no endtime. A finished task with an
  empty status shows "unknown" and is flagged.

Read-only live check: 25 tasks listed, and the log of the newest
(vzdelsnapshot 104) ends with TASK OK.

Closes #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #25.
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-03 00:03:58 +01:00
1 parent eec65e0585
commit 1ed5e5069b
6 files changed
+546

No files matched your search

+127
View File
@@ -18,6 +18,7 @@ from textual.widgets import (
Header,
Input,
Label,
Log,
OptionList,
Select,
Static,
@@ -417,6 +418,124 @@ class NetScreen(DialogScreen):
)
class TaskLogScreen(DialogScreen):
"""The log of one task, as plain text (task output is never parsed as markup)."""
BINDINGS = [("escape", "dismiss", "Close")] # noqa: RUF012 - Textual idiom
DEFAULT_CSS = """
TaskLogScreen #dialog { width: 120; height: 90%; }
TaskLogScreen Log { height: 1fr; }
"""
def __init__(self, entry: dict) -> None:
super().__init__()
self.entry = entry
def compose(self) -> ComposeResult:
t = self.entry
with self.box(f"Log: {t.get('type', '')} {t.get('id') or t.get('node', '')}"):
yield Log(id="tasklog")
def on_mount(self) -> None:
self._load_worker()
@work(thread=True)
def _load_worker(self) -> None:
try:
text = cli.task_log(self.entry["upid"])
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
return
self.app.call_from_thread(self.query_one("#tasklog", Log).write, text)
def _duration(task: dict) -> str:
end, start = task.get("endtime"), task.get("starttime")
if not end:
return "running"
if not start:
return "-" # finished, but the start is unknown
secs = max(0, int(end - start))
if secs >= 3600:
return f"{secs // 3600}h {secs % 3600 // 60}m"
return f"{secs // 60}m {secs % 60}s" if secs >= 60 else f"{secs}s"
class TasksScreen(ModalScreen):
"""Recent cluster tasks, or those of one guest (vmid); Enter shows a task's log."""
BINDINGS = [ # noqa: RUF012 - Textual idiom
("escape", "dismiss", "Close"),
("g", "reload", "Refresh"),
]
DEFAULT_CSS = """
TasksScreen { align: center middle; }
TasksScreen #tasks_section {
width: 100%; height: 100%; border: round $accent; border-title-style: bold;
padding: 0 1; background: $surface;
}
TasksScreen DataTable { height: 1fr; }
"""
def __init__(self, guest: dict | None = None) -> None:
super().__init__()
self.guest = guest
def compose(self) -> ComposeResult:
title = "Tasks"
if self.guest:
title += f": {self.guest['name']} ({self.guest['vmid']})"
with Vertical(id="tasks_section") as section:
section.border_title = Text(title) # guest names: never markup
yield DataTable(id="tasks", cursor_type="row")
yield Static("", id="tasks_note", markup=False)
yield Footer()
def on_mount(self) -> None:
self.rows: list[dict] = []
self.query_one("#tasks", DataTable).add_columns(
"", "Start", "Duration", "Node", "Type", "VMID", "User", "Status"
)
self.action_reload()
def action_reload(self) -> None:
self._load_worker()
@work(thread=True)
def _load_worker(self) -> None:
try:
tasks = cli.tasks()
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
return
if self.guest:
tasks = [t for t in tasks if t.get("id") == str(self.guest["vmid"])]
self.app.call_from_thread(self._fill, tasks)
def _fill(self, tasks: list[dict]) -> None:
self.rows = tasks
table = self.query_one("#tasks", DataTable)
table.clear()
for t in tasks:
# running = no endtime yet; a finished task without a status text is "unknown"
status = "running" if not t.get("endtime") else t.get("status") or "unknown"
_add_plain(
table,
"!" if status not in ("OK", "running") else "",
_when(t.get("starttime")),
_duration(t),
t.get("node", ""),
t.get("type", ""),
t.get("id") or "-",
t.get("user", ""),
status,
)
self.query_one("#tasks_note", Static).update("" if tasks else "No tasks")
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
self.app.push_screen(TaskLogScreen(self.rows[event.cursor_row]))
class InfoScreen(ModalScreen):
"""Backups, backup job and network of one guest (read-only), plus backup now."""
@@ -431,6 +550,7 @@ class InfoScreen(ModalScreen):
("p", "snapshot", "Take snapshot"),
("R", "rollback", "Roll back"),
("D", "delete_snapshot", "Delete snapshot"),
("l", "tasks", "Tasks"),
]
DEFAULT_CSS = """
InfoScreen DataTable { height: auto; max-height: 12; }
@@ -501,6 +621,9 @@ class InfoScreen(ModalScreen):
def action_reload(self) -> None:
self._load_worker()
def action_tasks(self) -> None:
self.app.push_screen(TasksScreen(self.guest))
@work(thread=True)
def _load_worker(self) -> None:
self.load()
@@ -808,6 +931,7 @@ class GuestApp(App):
("t", "shell", "Shell"),
("g", "reload", "Refresh"),
("c", "context", "Context"),
("l", "tasks", "Tasks"),
("q", "quit", "Quit"),
]
@@ -856,6 +980,9 @@ class GuestApp(App):
self._show_context()
self.action_reload()
def action_tasks(self) -> None:
self.push_screen(TasksScreen())
def _show_context_frame(self, sub_title: str) -> None:
self.context_name = sub_title.removeprefix("context: ")
self._update_frame()