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
@@ -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
|
||||
Reference in new issue
Block a user