Add the task log: recent tasks with status, Enter shows the log
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:
1 parent
eec65e0585
commit
1ed5e5069b
6 files changed
+546
No files matched your search
@@ -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()
|
||||
|
||||
@@ -448,3 +448,15 @@ def delete_snapshot(guest: dict, name: str) -> None:
|
||||
if args[2] != "snapshot" or args[4:6] != (str(guest["vmid"]), name):
|
||||
raise RuntimeError("refusing to send a delete that is not a snapshot delete")
|
||||
run(*args)
|
||||
|
||||
|
||||
def tasks() -> list[dict]:
|
||||
"""Recent cluster tasks, newest first; running ones lack endtime and status."""
|
||||
items = json.loads(run("api", "get", "/cluster/tasks", "-o", "json"))
|
||||
return sorted(items, key=lambda t: t.get("starttime") or 0, reverse=True)
|
||||
|
||||
|
||||
def task_log(upid: str) -> str:
|
||||
if not isinstance(upid, str) or not upid.startswith("UPID:"):
|
||||
raise ValueError("not a task id (UPID)")
|
||||
return run("task", "log", upid)
|
||||
Reference in new issue
Block a user