Add search and filter to the guest table
CI / pytest and ruff (pull_request) Successful in 4m48s
CI / pytest and ruff (push) Has been cancelled

/ opens a filter box under the guest table, and the table filters as
you type. Terms are separated by spaces, all must match, and case is
ignored:

- a plain term matches part of the name, the VMID or a tag
- is:running and is:stopped match the status
- type:lxc/ct and type:vm/qemu match the guest type

Enter keeps the filter and goes back to the table. Escape clears it,
from the box or from the table. The frame shows
"· filter: <text> · N of M". Actions only ever see the visible rows,
and the filter survives reloads and context switches.

Review fixes:
- Typing "[/b]" or "[/]" crashed the app, because the filter text went
  into the border subtitle, which Textual parses as markup. The
  subtitle is now plain text, so "[red]" is also shown literally.
- Escape on the table now clears a filter that was applied with Enter.
- guest_matches tolerates a None status or type.

Read-only live check on mainframe: "debian" gives 104, "type:vm" gives
108, "community-script is:running" gives 14 of 19, and "is:stopped"
gives 0 of 19.

Closes #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #29.
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-03 00:42:13 +01:00
1 parent ff6345c80f
commit d97ad22c78
4 files changed
+481 -7

No files matched your search

+88 -7
View File
@@ -23,6 +23,7 @@ from textual.widgets import (
Select,
Static,
)
from textual.widgets._data_table import RowDoesNotExist
from textual.widgets.option_list import Option
from cv4pve_tui import cli
@@ -991,12 +992,45 @@ def _job_text(job: dict) -> str:
)
_TYPES = {"lxc": "lxc", "ct": "lxc", "vm": "qemu", "qemu": "qemu"}
def guest_matches(guest: dict, query: str) -> bool:
"""True if the guest matches every space-separated term (case-insensitive).
Plain term: substring of the name, the VMID or one tag. `is:running|stopped`
matches the status, `type:lxc|ct|vm|qemu` the type; an unknown value matches nothing.
"""
tags = (guest.get("tags") or "").lower().split(";")
for term in query.lower().split():
key, _, value = term.partition(":")
if key == "is" and value:
ok = (guest.get("status") or "").lower() == value
elif key == "type" and value:
ok = _TYPES.get(value) == (guest.get("type") or "").lower()
else:
ok = (
term in str(guest.get("name", "")).lower()
or term in str(guest.get("vmid", ""))
or any(term in t for t in tags)
)
if not ok:
return False
return True
class GuestTable(DataTable):
"""The guest table; shows its Enter (row selected -> info screen) in the footer."""
BINDINGS = [Binding("enter", "select_cursor", "Info")] # noqa: RUF012 - Textual idiom
class FilterInput(Input):
"""The filter box; Escape clears and hides it."""
BINDINGS = [Binding("escape", "app.clear_filter", "Clear filter")] # noqa: RUF012 - Textual idiom
class GuestApp(App):
BINDINGS = [ # noqa: RUF012 - Textual idiom
("s", "act('start')", "Start"),
@@ -1006,9 +1040,12 @@ class GuestApp(App):
("t", "shell", "Shell"),
("g", "reload", "Refresh"),
("c", "context", "Context"),
("slash", "filter", "Filter"),
("l", "tasks", "Tasks"),
("U", "updates", "Updates"),
("q", "quit", "Quit"),
# also on the table, once a filter was applied with Enter (focus left the input)
Binding("escape", "clear_filter", "Clear filter", show=False),
]
def __init__(self) -> None:
@@ -1028,6 +1065,7 @@ class GuestApp(App):
border: round $accent; border-title-style: bold; padding: 0 1;
}
#guests_section DataTable { height: 1fr; }
#filter { display: none; }
"""
def compose(self) -> ComposeResult:
@@ -1035,6 +1073,9 @@ class GuestApp(App):
with Vertical(id="guests_section") as section:
section.border_title = "Guests"
yield GuestTable(id="guests", cursor_type="row")
yield FilterInput(
id="filter", placeholder="name, vmid, tag · is:running · type:lxc"
)
yield Footer()
def on_mount(self) -> None:
@@ -1111,10 +1152,17 @@ 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 ""
query = self.query_one("#filter", Input).value.strip()
if query:
shown = self.query_one("#guests", DataTable).row_count
count = f" · filter: {query} · {shown} of {len(self.guests)}" + count
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
# plain text: the filter is typed by the user and must never be parsed as markup
self.query_one("#guests_section").border_subtitle = Text(
self.context_name + count
)
@work(thread=True)
def _show_context(self) -> None:
@@ -1150,11 +1198,22 @@ class GuestApp(App):
if gen != self.generation: # listed before a context switch: old cluster
return
self.unprotected = unprotected or set()
table = self.query_one("#guests", DataTable)
current = self._selected()
table.clear()
self.guests = {str(g["vmid"]): g for g in guests}
for g in guests:
self._show_guests()
def _show_guests(self) -> None:
"""Rebuild the table from self.guests: only the guests matching the filter."""
table = self.query_one("#guests", DataTable)
current = ( # the key, not _selected(): self.guests may already be the new listing
table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
if table.row_count
else None
)
table.clear()
query = self.query_one("#filter", Input).value
for g in self.guests.values():
if not guest_matches(g, query):
continue
_add_plain(table,
"!" if g["vmid"] in self.unprotected else "",
g["vmid"], g["name"], g["type"], g["node"], g["status"],
@@ -1162,10 +1221,32 @@ class GuestApp(App):
*_percents(g),
key=str(g["vmid"]),
) # fmt: skip
if current and str(current["vmid"]) in self.guests:
table.move_cursor(row=table.get_row_index(str(current["vmid"])))
if current is not None:
try:
table.move_cursor(row=table.get_row_index(current))
except RowDoesNotExist: # gone or filtered out: keep the default cursor
pass
self._update_frame()
def action_filter(self) -> None:
box = self.query_one("#filter", Input)
box.display = True
box.focus()
def action_clear_filter(self) -> None:
box = self.query_one("#filter", Input)
box.value = "" # Input.Changed rebuilds the table
box.display = False
self.query_one("#guests", DataTable).focus()
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "filter":
self._show_guests()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "filter":
self.query_one("#guests", DataTable).focus()
def _reload(self, acted: dict | None = None) -> None:
"""Runs in a worker thread. `acted` gets its live status: the listing lags."""
gen = self.generation