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

+321
View File
@@ -74,6 +74,12 @@ class Fake:
# issue #12 (live usage); NB the name "current" is taken by the context-name attribute above
self.usage = {} # vmid -> overrides merged into the status/current dict
self.current_error, self.current_calls = None, []
# issue #10 (task log)
self.task_list = [
dict(t) for t in TASKS
] # newest first, as cli.tasks returns them
self.tasks_error, self.tasks_calls = None, 0
self.log_text, self.log_error, self.log_calls = "TASK OK\n", None, []
def list_guests(self):
self.list_calls += 1
@@ -213,7 +219,31 @@ class Fake:
def delete_snapshot(self, guest, name):
self._snap_op(self.delete_calls, (guest["vmid"], name))
def tasks(self):
self.tasks_calls += 1
if self.tasks_error:
raise self.tasks_error
return [dict(t) for t in self.task_list]
def task_log(self, upid):
self.log_calls.append(upid)
if self.log_error:
raise self.log_error
return self.log_text
T_TASK = 1_762_600_000
TASKS = [ # newest first; one running (no endtime/status), one failed, one node task
{"upid": "UPID:mainframe:0001:qmstop:108:root@pam:", "type": "qmstop", "id": "108", "node": "mainframe",
"user": "root@pam", "starttime": T_TASK + 1000},
{"upid": "UPID:mainframe:0002:qmstart:108:root@pam:", "type": "qmstart", "id": "108", "node": "mainframe",
"user": "root@pam", "starttime": T_TASK, "endtime": T_TASK + 192, "status": "OK"},
{"upid": "UPID:mainframe:0003:aptupdate::root@pam:", "type": "aptupdate", "id": "", "node": "mainframe",
"user": "root@pam", "starttime": T_TASK - 5000, "endtime": T_TASK - 5000 + 3720, "status": "OK"},
{"upid": "UPID:mainframe:0004:vzsnapshot:100:root@pam:", "type": "vzsnapshot", "id": "100", "node": "mainframe",
"user": "root@pam", "starttime": T_TASK - 9000, "endtime": T_TASK - 9000 + 45,
"status": "snapshot 'x' already exists"},
] # fmt: skip
BACKUP_JOB = {"id": "backup-1", "enabled": 1, "storage": "truenas-backup", "all": 1,
"vmid": None, "exclude": None, "node": None, "schedule": "mon,wed,fri 04:00",
"mode": "snapshot", "compress": "zstd", "prune-backups": {"keep-last": "3"}} # fmt: skip
@@ -255,6 +285,8 @@ def fake(monkeypatch):
"take_snapshot",
"rollback_snapshot",
"delete_snapshot",
"tasks",
"task_log",
):
monkeypatch.setattr(cli, name, getattr(f, name), raising=False)
for name in ("list_guests", "guest_config", "disks", "action", "set_resources",
@@ -2850,3 +2882,292 @@ async def test_table_usage_of_acted_guest_comes_from_live_status(fake):
row = [str(c) for c in app.query_one("#guests", DataTable).get_row("100")]
assert "running" in row
assert row[-2:] == ["50.0", "50.0"]
# --- issue #10: task log -----------------------------------------------------------
# spec: docs/plans/guest-tui-mvp.md ("Task log (issue #10)" > app.py)
TASK_COLUMNS = 8 # leading "!" column + Start, Duration, Node, Type, VMID, User, Status
async def open_tasks(app, pilot):
await pilot.press("l")
await settle(app, pilot)
assert isinstance(app.screen, appmod.TasksScreen)
return app.screen.query_one("#tasks", DataTable)
def log_text(widget):
"""Text of the #tasklog widget, whatever the widget class (the spec only says read-only plain text)."""
if hasattr(widget, "text"):
return widget.text
if hasattr(widget, "lines"):
return "\n".join(str(getattr(ln, "text", ln)) for ln in widget.lines)
return str(widget.render())
def local_task_date(epoch):
return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M")
async def test_l_on_main_screen_lists_all_tasks_newest_first(fake): # `l` main screen
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_tasks(app, pilot)
assert table.row_count == 4
assert [cells(table, i)[4] for i in range(4)] == [
"qmstop",
"qmstart",
"aptupdate",
"vzsnapshot",
]
assert app.screen.query_one("#tasks_section").border_title == "Tasks"
assert table.cursor_type == "row"
async def test_tasks_columns_and_formatting(
fake,
): # columns, duration, VMID "-", Status
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_tasks(app, pilot)
assert len(table.columns) == TASK_COLUMNS
running, ok, node_task, failed = (cells(table, i) for i in range(4))
assert ok[1:] == [
local_task_date(T_TASK),
"3m 12s",
"mainframe",
"qmstart",
"108",
"root@pam",
"OK",
]
assert running[1:] == [local_task_date(T_TASK + 1000), "running", "mainframe", "qmstop", "108",
"root@pam", "running"] # fmt: skip
assert node_task[2] == "1h 2m"
assert node_task[5] == "-" # node task has no VMID
assert failed[2] == "45s"
assert failed[7] == "snapshot 'x' already exists"
async def test_failed_task_gets_bang_in_leading_column_only(fake): # "!" for failed
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_tasks(app, pilot)
marks = [cells(table, i)[0].strip() for i in range(4)]
assert marks == ["", "", "", "!"] # running, OK, OK, failed
async def test_l_on_info_screen_shows_only_that_guests_tasks(
fake,
): # `l` info screen, vmid filter
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot) # VM 108 docker
table = await open_tasks(app, pilot)
assert [cells(table, i)[4] for i in range(table.row_count)] == [
"qmstop",
"qmstart",
]
assert (
app.screen.query_one("#tasks_section").border_title == "Tasks: docker (108)"
)
async def test_info_tasks_filter_matches_vmid_exactly(
fake,
): # id == str(vmid), not a prefix
fake.task_list = [
dict(TASKS[1], id="1080"),
dict(TASKS[1], id="10"),
dict(TASKS[1], id="108"),
]
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
table = await open_tasks(app, pilot)
assert [cells(table, i)[5] for i in range(table.row_count)] == ["108"]
async def test_no_tasks_note_when_empty(fake): # Static#tasks_note "No tasks"
fake.task_list = []
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_tasks(app, pilot)
assert table.row_count == 0
assert "No tasks" in str(app.screen.query_one("#tasks_note", Static).render())
async def test_info_tasks_empty_for_guest_without_tasks(
fake,
): # filter leaving nothing -> "No tasks"
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(
app, pilot, down=1
) # CT 100; only a vzsnapshot task of 100 exists
table = await open_tasks(app, pilot)
assert table.row_count == 1
fake.task_list = [t for t in TASKS if t["id"] != "100"]
await pilot.press("g")
await settle(app, pilot)
assert table.row_count == 0
assert "No tasks" in str(app.screen.query_one("#tasks_note", Static).render())
async def test_tasks_failure_notifies_error_and_app_keeps_running(
fake,
): # failure -> error notify
fake.tasks_error = cli_error("Error: tasks boom")
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await open_tasks(app, pilot)
assert any("boom" in e for e in errors(app))
assert isinstance(app.screen, appmod.TasksScreen) and app.is_running
async def test_enter_opens_log_of_the_cursor_tasks_upid(fake): # Enter -> TaskLogScreen
fake.log_text = "starting\nTASK OK\n"
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await open_tasks(app, pilot)
await pilot.press("down") # second row: qmstart
await pilot.press("enter")
await settle(app, pilot)
assert isinstance(app.screen, appmod.TaskLogScreen)
assert fake.log_calls == [TASKS[1]["upid"]]
assert "TASK OK" in log_text(app.screen.query_one("#tasklog"))
assert str(app.screen.query_one("#dialog").border_title) == "Log: qmstart 108"
async def test_log_title_for_node_task_names_the_node(
fake,
): # title "<type> <vmid or node>"
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await open_tasks(app, pilot)
await pilot.press("down", "down", "enter") # aptupdate, no vmid
await settle(app, pilot)
assert (
str(app.screen.query_one("#dialog").border_title)
== "Log: aptupdate mainframe"
)
async def test_log_with_markup_like_text_is_shown_literally(fake): # no markup parsing
fake.log_text = "error in [/b] and [bold]x[/bold] and [red\nTASK OK\n"
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await open_tasks(app, pilot)
await pilot.press("enter")
await settle(app, pilot)
assert isinstance(app.screen, appmod.TaskLogScreen) and app.is_running
shown = log_text(app.screen.query_one("#tasklog"))
assert "error in [/b] and [bold]x[/bold] and [red" in shown
async def test_task_log_failure_notifies_error(
fake,
): # task_log failure -> error notify
fake.log_error = cli_error("Error: log boom")
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await open_tasks(app, pilot)
await pilot.press("enter")
await settle(app, pilot)
assert any("boom" in e for e in errors(app))
assert app.is_running
async def test_g_reloads_tasks(fake): # `g` reload
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_tasks(app, pilot)
before = fake.tasks_calls
fake.task_list = fake.task_list[:2]
await pilot.press("g")
await settle(app, pilot)
assert fake.tasks_calls == before + 1
assert table.row_count == 2
async def test_escape_closes_log_then_tasks_then_returns_to_info(
fake,
): # escape closes each screen
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
await open_tasks(app, pilot)
await pilot.press("enter")
await settle(app, pilot)
assert isinstance(app.screen, appmod.TaskLogScreen)
await pilot.press("escape")
await settle(app, pilot)
assert isinstance(app.screen, appmod.TasksScreen)
await pilot.press("escape")
await settle(app, pilot)
assert isinstance(app.screen, appmod.InfoScreen)
async def test_escape_closes_tasks_screen_on_main(fake): # escape closes
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await open_tasks(app, pilot)
await pilot.press("escape")
await settle(app, pilot)
assert not isinstance(app.screen, appmod.TasksScreen)
assert app.is_running
async def test_footer_on_main_screen_shows_l_tasks(fake): # footer `l Tasks`
app = make_app()
async with app.run_test() as pilot:
await settle(app, pilot)
shown = [ab.binding for ab in app.active_bindings.values() if ab.binding.show]
assert any(b.key == "l" and b.description == "Tasks" for b in shown)
# --- #10 review findings ----------------------------------------------------------------
async def test_task_with_endtime_but_no_starttime_does_not_crash(fake):
fake.task_list = [
{"upid": "UPID:mainframe:a:vzdump::root@pam:", "type": "vzdump", "id": "", "node": "mainframe",
"user": "root@pam", "endtime": T_TASK, "status": "OK"},
{"upid": "UPID:mainframe:b:vzdump::root@pam:", "type": "vzdump", "id": "", "node": "mainframe",
"user": "root@pam", "starttime": None, "endtime": T_TASK, "status": "OK"},
] # fmt: skip
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_tasks(app, pilot)
assert table.row_count == 2 and app.is_running
assert [str(table.get_row_at(i)[2]) for i in range(2)] == [
"-",
"-",
] # duration unknown
async def test_finished_task_with_empty_status_is_not_shown_as_running(fake):
fake.task_list = [
{"upid": "UPID:mainframe:c:qmstart:108:root@pam:", "type": "qmstart", "id": "108", "node": "mainframe",
"user": "root@pam", "starttime": T_TASK, "endtime": T_TASK + 5, "status": ""},
] # fmt: skip
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_tasks(app, pilot)
row = [str(c) for c in table.get_row_at(0)]
assert "running" not in row
assert row[0] == "!" # finished without OK: flagged
+45
View File
@@ -1295,3 +1295,48 @@ def test_take_snapshot_refuses_description_that_looks_like_an_option(fake):
{"vmid": 104, "node": "mainframe", "type": "lxc"}, "snap1", "-y"
)
assert fake.calls == []
# --- issue #10: task log (spec: "Task log (issue #10)" > cli.py) ------------------------
UPID = "UPID:mainframe:0001:00A:68F:qmstart:108:root@pam:"
def test_tasks_argv_is_cluster_tasks_json(fake): # tasks(): api get /cluster/tasks
fake.result = ("[]", "", 0)
assert cli.tasks() == []
assert fake.argv == ["cv4pve-cli", "api", "get", "/cluster/tasks", "-o", "json"]
def test_tasks_newest_first_including_running_without_endtime(
fake,
): # tasks(): newest first
raw = [
{"upid": "a", "starttime": 100, "endtime": 150, "status": "OK"},
{"upid": "c", "starttime": 300}, # running: no endtime, no status
{"upid": "b", "starttime": 200, "endtime": None},
]
fake.result = (json.dumps(raw), "", 0)
assert [t["upid"] for t in cli.tasks()] == ["c", "b", "a"]
def test_tasks_failure_raises_clierror(monkeypatch): # failure surfaces as CliError
FakeRun(monkeypatch, "Error: boom", returncode=1)
with pytest.raises(cli.CliError):
cli.tasks()
def test_task_log_argv_and_returns_text(fake): # task_log(): task log <upid>
fake.result = ("starting\nTASK OK\n", "", 0)
out = cli.task_log(UPID)
assert fake.argv == ["cv4pve-cli", "task", "log", UPID]
assert "TASK OK" in out
@pytest.mark.parametrize("bad", ["-y", "--help", "", "108", "x UPID:a"])
def test_task_log_rejects_non_upid_without_calling_subprocess(
fake, bad
): # ValueError unless UPID:
with pytest.raises(ValueError):
cli.task_log(bad)
assert fake.calls == []