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
@@ -536,6 +536,81 @@ class TasksScreen(ModalScreen):
|
||||
self.app.push_screen(TaskLogScreen(self.rows[event.cursor_row]))
|
||||
|
||||
|
||||
def _collect_updates() -> tuple[list[dict], list[str]]:
|
||||
"""Runs in a worker thread: (rows over all online nodes, one note per failed node)."""
|
||||
rows, notes = [], []
|
||||
for node in cli.nodes():
|
||||
try:
|
||||
rows += [{**p, "node": node} for p in cli.package_updates(node)]
|
||||
except Exception as exc: # noqa: BLE001 - a failing node must not hide the others
|
||||
notes.append(f"{node}: unavailable: {_message(exc)}")
|
||||
return rows, notes
|
||||
|
||||
|
||||
class UpdatesScreen(ModalScreen):
|
||||
"""Pending package updates of every online node (read-only)."""
|
||||
|
||||
BINDINGS = [ # noqa: RUF012 - Textual idiom
|
||||
("escape", "dismiss", "Close"),
|
||||
("g", "reload", "Refresh"),
|
||||
]
|
||||
DEFAULT_CSS = """
|
||||
UpdatesScreen { align: center middle; }
|
||||
UpdatesScreen #updates_section {
|
||||
width: 100%; height: 100%; border: round $accent; border-title-style: bold;
|
||||
padding: 0 1; background: $surface;
|
||||
}
|
||||
UpdatesScreen DataTable { height: 1fr; }
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="updates_section") as section:
|
||||
section.border_title = "Package updates"
|
||||
yield DataTable(id="updates", cursor_type="row")
|
||||
yield Static("", id="updates_note", markup=False)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#updates", DataTable).add_columns(
|
||||
"Node", "Package", "Current", "New", "Origin", "Priority"
|
||||
)
|
||||
self.action_reload()
|
||||
|
||||
def action_reload(self) -> None:
|
||||
self._load_worker()
|
||||
|
||||
@work(thread=True)
|
||||
def _load_worker(self) -> None:
|
||||
gen = self.app.generation
|
||||
try:
|
||||
rows, notes = _collect_updates()
|
||||
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._fill, rows, notes)
|
||||
self.app.call_from_thread(
|
||||
self.app._set_count, None if notes else len(rows), gen
|
||||
)
|
||||
|
||||
def _fill(self, rows: list[dict], notes: list[str]) -> None:
|
||||
table = self.query_one("#updates", DataTable)
|
||||
table.clear()
|
||||
for p in rows:
|
||||
_add_plain(
|
||||
table,
|
||||
p["node"],
|
||||
p.get("Package", ""),
|
||||
p.get("OldVersion", ""),
|
||||
p.get("Version", ""),
|
||||
p.get("Origin", ""),
|
||||
p.get("Priority", ""),
|
||||
)
|
||||
lines = [] if rows else ["No pending updates"]
|
||||
lines += notes
|
||||
lines.append("Lists are as of each node's last apt update; it is not run here.")
|
||||
self.query_one("#updates_note", Static).update("\n".join(lines))
|
||||
|
||||
|
||||
class InfoScreen(ModalScreen):
|
||||
"""Backups, backup job and network of one guest (read-only), plus backup now."""
|
||||
|
||||
@@ -932,6 +1007,7 @@ class GuestApp(App):
|
||||
("g", "reload", "Refresh"),
|
||||
("c", "context", "Context"),
|
||||
("l", "tasks", "Tasks"),
|
||||
("U", "updates", "Updates"),
|
||||
("q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
@@ -945,6 +1021,7 @@ class GuestApp(App):
|
||||
self.switching = False
|
||||
self.context_name = ""
|
||||
self.unprotected: set[int] = set() # VMIDs no backup job covers
|
||||
self.package_count: int | None = None # pending package updates; None = unknown
|
||||
|
||||
CSS = """
|
||||
#guests_section {
|
||||
@@ -979,10 +1056,34 @@ class GuestApp(App):
|
||||
)
|
||||
self._show_context()
|
||||
self.action_reload()
|
||||
self._count_worker()
|
||||
|
||||
def action_tasks(self) -> None:
|
||||
self.push_screen(TasksScreen())
|
||||
|
||||
def action_updates(self) -> None:
|
||||
self.push_screen(UpdatesScreen())
|
||||
|
||||
@work(thread=True)
|
||||
def _count_worker(self) -> None:
|
||||
self._refresh_count()
|
||||
|
||||
def _refresh_count(self) -> None:
|
||||
"""Runs in a worker thread: any failure leaves the count unknown, silently."""
|
||||
gen = self.generation
|
||||
try:
|
||||
rows, notes = _collect_updates()
|
||||
count = None if notes else len(rows)
|
||||
except Exception: # noqa: BLE001 - the count is optional; no notify on failure
|
||||
count = None
|
||||
self.call_from_thread(self._set_count, count, gen)
|
||||
|
||||
def _set_count(self, count: int | None, gen: int) -> None:
|
||||
if gen != self.generation: # fetched before a context switch: old cluster
|
||||
return
|
||||
self.package_count = count
|
||||
self._update_frame()
|
||||
|
||||
def _show_context_frame(self, sub_title: str) -> None:
|
||||
self.context_name = sub_title.removeprefix("context: ")
|
||||
self._update_frame()
|
||||
@@ -990,6 +1091,9 @@ class GuestApp(App):
|
||||
def _update_frame(self) -> None:
|
||||
n = len(self.unprotected & {g["vmid"] for g in self.guests.values()})
|
||||
count = f" · {n} without backup job" if n else ""
|
||||
if self.package_count:
|
||||
n = self.package_count
|
||||
count += f" · {n} package update{'s' if n != 1 else ''}"
|
||||
self.query_one("#guests_section").border_subtitle = self.context_name + count
|
||||
|
||||
@work(thread=True)
|
||||
@@ -1112,6 +1216,8 @@ class GuestApp(App):
|
||||
).clear() # old cluster's guests: not actionable
|
||||
self.guests = {}
|
||||
self.undo.clear() # VMIDs repeat across clusters: never undo onto another cluster
|
||||
self.package_count = None
|
||||
self._update_frame()
|
||||
self.generation += 1
|
||||
self.switching = True
|
||||
self._switch_context(name)
|
||||
@@ -1127,6 +1233,7 @@ class GuestApp(App):
|
||||
else:
|
||||
self.call_from_thread(setattr, self, "sub_title", f"context: {name}")
|
||||
self._reload()
|
||||
self._refresh_count()
|
||||
finally:
|
||||
self.call_from_thread(setattr, self, "switching", False)
|
||||
|
||||
|
||||
@@ -456,6 +456,18 @@ def tasks() -> list[dict]:
|
||||
return sorted(items, key=lambda t: t.get("starttime") or 0, reverse=True)
|
||||
|
||||
|
||||
def nodes() -> list[str]:
|
||||
"""Names of the online nodes, sorted."""
|
||||
items = json.loads(run("get", "nodes", "-o", "json"))
|
||||
return sorted(n["node"] for n in items if n.get("status") == "online")
|
||||
|
||||
|
||||
def package_updates(node: str) -> list[dict]:
|
||||
"""Pending package updates of a node (as of its last `apt update`), by package."""
|
||||
items = json.loads(run("get", "node", "packages", node, "-o", "json"))
|
||||
return sorted(items, key=lambda p: p.get("Package", ""))
|
||||
|
||||
|
||||
def task_log(upid: str) -> str:
|
||||
if not isinstance(upid, str) or not upid.startswith("UPID:"):
|
||||
raise ValueError("not a task id (UPID)")
|
||||
|
||||
Reference in new issue
Block a user