Show pending node package updates (read-only)
U opens "Package updates": the pending apt updates of every online node, with node, package, current and new version, origin and priority. A node that fails is shown as "<node>: unavailable", and the other nodes are still listed. A hint says the list is as of the node's last apt update; this call does not refresh it. The main frame's bottom border shows "· N package updates" after the backup count, with a singular for 1. The count is fetched on mount, after a context switch and whenever the updates screen loads, not on every guest reload, since it costs one CLI call per node. It uses the same generation guard as the guest list, so a count from the old cluster is dropped after a switch. Running apt update or upgrade stays out of scope; it is a write. Read-only live check: "mainframe · 23 package updates", and 23 rows in the screen. Closes #13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #26.
This commit is contained in:
1 parent
1ed5e5069b
commit
364f8e2137
6 files changed
+493
No files matched your search
@@ -80,6 +80,12 @@ class Fake:
|
||||
] # 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, []
|
||||
# issue #13 (node package updates): node -> rows; one online node, nothing pending
|
||||
self.updates = {"mainframe": []}
|
||||
self.updates_by_context = {} # context name -> {node: rows}, overrides self.updates
|
||||
self.nodes_error, self.updates_errors = None, {} # node -> exception
|
||||
self.nodes_calls, self.updates_calls = 0, []
|
||||
self.updates_hook = None
|
||||
|
||||
def list_guests(self):
|
||||
self.list_calls += 1
|
||||
@@ -225,6 +231,26 @@ class Fake:
|
||||
raise self.tasks_error
|
||||
return [dict(t) for t in self.task_list]
|
||||
|
||||
def _cluster(self):
|
||||
return self.updates_by_context.get(self.current, self.updates)
|
||||
|
||||
def nodes(self):
|
||||
self.nodes_calls += 1
|
||||
if self.nodes_error:
|
||||
raise self.nodes_error
|
||||
return sorted(self._cluster())
|
||||
|
||||
def package_updates(self, node):
|
||||
self.updates_calls.append(node)
|
||||
rows = [dict(r) for r in self._cluster()[node]]
|
||||
if (
|
||||
self.updates_hook
|
||||
): # runs after reading: simulates a slow reply from that cluster
|
||||
self.updates_hook()
|
||||
if node in self.updates_errors:
|
||||
raise self.updates_errors[node]
|
||||
return rows
|
||||
|
||||
def task_log(self, upid):
|
||||
self.log_calls.append(upid)
|
||||
if self.log_error:
|
||||
@@ -287,6 +313,8 @@ def fake(monkeypatch):
|
||||
"delete_snapshot",
|
||||
"tasks",
|
||||
"task_log",
|
||||
"nodes",
|
||||
"package_updates",
|
||||
):
|
||||
monkeypatch.setattr(cli, name, getattr(f, name), raising=False)
|
||||
for name in ("list_guests", "guest_config", "disks", "action", "set_resources",
|
||||
@@ -3171,3 +3199,275 @@ async def test_finished_task_with_empty_status_is_not_shown_as_running(fake):
|
||||
row = [str(c) for c in table.get_row_at(0)]
|
||||
assert "running" not in row
|
||||
assert row[0] == "!" # finished without OK: flagged
|
||||
|
||||
|
||||
# --- #13: node package updates ------------------------------------------------------------
|
||||
# spec: docs/plans/guest-tui-mvp.md ("Node package updates (issue #13)")
|
||||
|
||||
|
||||
def pkg(name, old="1.0", new="2.0", origin="Debian", priority="optional"):
|
||||
return {"Package": name, "OldVersion": old, "Version": new, "Title": f"{name} title",
|
||||
"Priority": priority, "Origin": origin} # fmt: skip
|
||||
|
||||
|
||||
def many(n, prefix="pkg"):
|
||||
return [pkg(f"{prefix}{i:02d}") for i in range(n)]
|
||||
|
||||
|
||||
def frame_subtitle(app):
|
||||
return str(app.query_one("#guests_section").border_subtitle)
|
||||
|
||||
|
||||
async def open_updates(app, pilot):
|
||||
await pilot.press("U")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.UpdatesScreen)
|
||||
return app.screen.query_one("#updates", DataTable)
|
||||
|
||||
|
||||
def note_of(app):
|
||||
return str(app.screen.query_one("#updates_note", Static).render())
|
||||
|
||||
|
||||
def two_nodes(fake):
|
||||
fake.updates = {
|
||||
"mainframe": [pkg("curl", "7.1", "7.2", "Debian", "optional"),
|
||||
pkg("pve-manager", "8.1", "8.2", "Proxmox", "important")],
|
||||
"edge": [pkg("bash", "5.1", "5.2", "Debian", "required")],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
async def test_u_lists_rows_across_nodes_with_all_columns(
|
||||
fake,
|
||||
): # UpdatesScreen rows + columns
|
||||
two_nodes(fake)
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
table = await open_updates(app, pilot)
|
||||
assert [str(c.label) for c in table.columns.values()] == [
|
||||
"Node", "Package", "Current", "New", "Origin", "Priority"] # fmt: skip
|
||||
assert table.row_count == 3
|
||||
assert {tuple(cells(table, i)) for i in range(3)} == {
|
||||
("mainframe", "curl", "7.1", "7.2", "Debian", "optional"),
|
||||
("mainframe", "pve-manager", "8.1", "8.2", "Proxmox", "important"),
|
||||
("edge", "bash", "5.1", "5.2", "Debian", "required"),
|
||||
}
|
||||
assert (
|
||||
app.screen.query_one("#updates_section").border_title == "Package updates"
|
||||
)
|
||||
|
||||
|
||||
async def test_updates_screen_shows_no_pending_updates_note_when_empty(
|
||||
fake,
|
||||
): # "No pending updates"
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
table = await open_updates(app, pilot)
|
||||
assert table.row_count == 0
|
||||
assert "No pending updates" in note_of(app)
|
||||
|
||||
|
||||
async def test_updates_screen_has_apt_update_freshness_hint(
|
||||
fake,
|
||||
): # fixed hint: as of last apt update
|
||||
two_nodes(fake)
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
await open_updates(app, pilot)
|
||||
assert "apt update" in note_of(app).lower()
|
||||
|
||||
|
||||
async def test_failing_node_is_unavailable_and_other_nodes_still_listed(
|
||||
fake,
|
||||
): # failing node != hidden others
|
||||
two_nodes(fake)
|
||||
err = cli.CliError("Error: node edge timed out")
|
||||
fake.updates_errors = {"edge": err}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
table = await open_updates(app, pilot)
|
||||
assert table.row_count == 2
|
||||
assert {cells(table, i)[0] for i in range(2)} == {"mainframe"}
|
||||
note = note_of(app)
|
||||
assert "edge: unavailable" in note and err.summary in note
|
||||
assert "mainframe: unavailable" not in note
|
||||
|
||||
|
||||
async def test_g_in_updates_screen_reloads(fake): # `g` reloads
|
||||
two_nodes(fake)
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
table = await open_updates(app, pilot)
|
||||
before = len(fake.updates_calls)
|
||||
fake.updates["edge"].append(pkg("zsh"))
|
||||
await pilot.press("g")
|
||||
await settle(app, pilot)
|
||||
assert len(fake.updates_calls) > before
|
||||
assert table.row_count == 4
|
||||
|
||||
|
||||
async def test_escape_closes_updates_screen(fake): # `escape` closes
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
await open_updates(app, pilot)
|
||||
await pilot.press("escape")
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, appmod.UpdatesScreen)
|
||||
assert app.is_running
|
||||
|
||||
|
||||
async def test_frame_subtitle_shows_package_update_count_after_mount(
|
||||
fake,
|
||||
): # "· N package updates"
|
||||
fake.updates = {"mainframe": many(23)}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
assert "· 23 package updates" in frame_subtitle(app)
|
||||
assert "mainframe" in frame_subtitle(app)
|
||||
|
||||
|
||||
async def test_frame_subtitle_count_sums_nodes(fake): # count over all online nodes
|
||||
two_nodes(fake)
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
assert "· 3 package updates" in frame_subtitle(app)
|
||||
|
||||
|
||||
async def test_no_package_count_when_nothing_pending(fake): # N > 0 only
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
assert frame_subtitle(app) == "mainframe"
|
||||
|
||||
|
||||
async def test_package_count_follows_backup_count_in_subtitle(
|
||||
fake,
|
||||
): # "after the backup count"
|
||||
fake.unprotected = {100}
|
||||
fake.updates = {"mainframe": many(23)}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
sub = frame_subtitle(app)
|
||||
assert "1 without backup job" in sub and "· 23 package updates" in sub
|
||||
assert sub.index("without backup job") < sub.index("package updates")
|
||||
|
||||
|
||||
async def test_plain_guest_reload_does_not_refetch_package_count(
|
||||
fake,
|
||||
): # not on every guest reload
|
||||
fake.updates = {"mainframe": many(23)}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
nodes_before, pkg_before, list_before = (
|
||||
fake.nodes_calls,
|
||||
len(fake.updates_calls),
|
||||
fake.list_calls,
|
||||
)
|
||||
assert nodes_before >= 1 and pkg_before >= 1
|
||||
await pilot.press("g")
|
||||
await settle(app, pilot)
|
||||
assert fake.list_calls > list_before
|
||||
assert (fake.nodes_calls, len(fake.updates_calls)) == (nodes_before, pkg_before)
|
||||
assert "· 23 package updates" in frame_subtitle(app)
|
||||
|
||||
|
||||
async def test_opening_updates_screen_refreshes_the_frame_count(
|
||||
fake,
|
||||
): # count fetched on every screen load
|
||||
fake.updates = {"mainframe": many(23)}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
fake.updates["mainframe"] = many(5)
|
||||
await open_updates(app, pilot)
|
||||
assert "· 5 package updates" in frame_subtitle(app)
|
||||
assert "23 package updates" not in frame_subtitle(app)
|
||||
|
||||
|
||||
async def test_context_switch_clears_old_count_then_shows_new_clusters_count(
|
||||
fake,
|
||||
): # cleared at switch start
|
||||
fake.updates = {"mainframe": many(23)}
|
||||
fake.updates_by_context = {"mainframe-edge": {"edge": many(4)}}
|
||||
fake.guests_by_context = {"mainframe-edge": [EDGE]}
|
||||
started, release = threading.Event(), threading.Event()
|
||||
fake.use_hook = lambda: (started.set(), release.wait(10))
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
assert "23 package updates" in frame_subtitle(app)
|
||||
try:
|
||||
await switch_to_edge(app, pilot)
|
||||
await until(pilot, started.is_set)
|
||||
await pilot.pause()
|
||||
assert "package updates" not in frame_subtitle(app)
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
sub = frame_subtitle(app)
|
||||
assert "· 4 package updates" in sub and "23" not in sub
|
||||
|
||||
|
||||
async def test_count_fetched_before_a_switch_but_arriving_after_it_is_dropped(
|
||||
fake,
|
||||
): # generation guard
|
||||
fake.updates = {"mainframe": many(23)}
|
||||
fake.updates_by_context = {"mainframe-edge": {"edge": many(4)}}
|
||||
fake.guests_by_context = {"mainframe-edge": [EDGE]}
|
||||
hook, started, release = blocking_once()
|
||||
fake.updates_hook = hook # the mount-time count reads mainframe, then stalls
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
try:
|
||||
await until(pilot, started.is_set)
|
||||
await switch_to_edge(app, pilot)
|
||||
await until(pilot, lambda: rows(app) == ["300"])
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
sub = frame_subtitle(app)
|
||||
assert "23" not in sub
|
||||
assert "· 4 package updates" in sub
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["nodes", "node"])
|
||||
async def test_count_failure_on_mount_shows_nothing_and_does_not_notify(
|
||||
fake, failure
|
||||
): # unknown count
|
||||
fake.updates = {"mainframe": many(23)}
|
||||
if failure == "nodes":
|
||||
fake.nodes_error = cli.CliError("Error: no route")
|
||||
else:
|
||||
fake.updates_errors = {"mainframe": cli.CliError("Error: no route")}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
assert "package updates" not in frame_subtitle(app)
|
||||
assert errors(app) == []
|
||||
assert app.query_one("#guests", DataTable).row_count == 2
|
||||
|
||||
|
||||
async def test_footer_on_main_screen_shows_u_updates(fake): # footer `U Updates`
|
||||
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 == "U" and b.description == "Updates" for b in shown)
|
||||
|
||||
|
||||
async def test_frame_subtitle_singular_for_one_package_update(fake):
|
||||
fake.updates = {"mainframe": many(1)}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await settle(app, pilot)
|
||||
assert frame_subtitle(app).endswith("· 1 package update")
|
||||
Reference in new issue
Block a user