Add search and filter to the guest table
/ 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:
1 parent
ff6345c80f
commit
d97ad22c78
4 files changed
+481
-7
No files matched your search
+88
-7
@@ -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
|
||||
|
||||
Reference in new issue
Block a user