Search and filter in the guest table #29

Merged
Duckboot merged 1 commits from feat/search-filter-16 into main 2026-11-03 01:33:06 +01:00
4 changed files with 481 additions and 7 deletions

No files matched your search

+1
View File
@@ -49,6 +49,7 @@ uv run cv4pve-tui
| `l` | tasks: recent cluster tasks (start, duration, node, type, VMID, user, status; `!` marks failures); `Enter` shows the log of the task under the cursor, `g` reloads. In info: `l` = only this guest's tasks | | `l` | tasks: recent cluster tasks (start, duration, node, type, VMID, user, status; `!` marks failures); `Enter` shows the log of the task under the cursor, `g` reloads. In info: `l` = only this guest's tasks |
| `U` | package updates: pending updates per online node (node, package, current, new, origin, priority), read-only; `g` reloads. The list is as of the node's last `apt update`. The frame's bottom border shows the count | | `U` | package updates: pending updates per online node (node, package, current, new, origin, priority), read-only; `g` reloads. The list is as of the node's last `apt update`. The frame's bottom border shows the count |
| `g` | refresh | | `g` | refresh |
| `/` | filter the guest table as you type: space-separated terms, all must match (case-insensitive); plain term = part of name, VMID or a tag; `is:running` / `is:stopped`; `type:lxc` (or `ct`) / `type:vm` (or `qemu`). `Enter` keeps the filter and returns to the table, `Escape` clears it. The frame's bottom border shows `filter: <text> · N of M` |
| `c` | switch cv4pve-cli context (cluster) | | `c` | switch cv4pve-cli context (cluster) |
| `q` | quit | | `q` | quit |
+28
View File
@@ -629,3 +629,31 @@ CLI facts (read-only probes):
is dropped after a switch) and is cleared when a switch starts. Failures leave the is dropped after a switch) and is cleared when a switch starts. Failures leave the
count unknown (nothing shown), no error notify on mount. count unknown (nothing shown), no error notify on mount.
- Main footer shows `U Updates`. - Main footer shows `U Updates`.
---
# Search and filter in the guest table (issue #16)
## Decision
- `/` on the main screen shows `Input#filter` (hidden until then) below the guest table
and focuses it. The table filters as you type.
- Filter text = space-separated terms, all must match (AND), case-insensitive:
- a plain term matches a substring of the name, the VMID or one of the tags
(`tags` from `get guests`, `;`-separated);
- `is:running` / `is:stopped` match the status;
- `type:lxc` / `type:ct` and `type:vm` / `type:qemu` match the guest type.
- Enter in the filter keeps the filter and moves focus to the table. Escape in the
filter clears it, hides the input and moves focus to the table.
- The frame's bottom border gets "· filter: <text> · N of M" while a filter is active,
after the context name and before the backup/package counts.
- Actions (s/S/x/r/t/Enter) act on the selected *visible* row only; the table only ever
contains the matching guests. A reload (g, after actions, context switch) keeps the
filter applied. A context switch keeps the filter text.
- Placeholder of the input shows the syntax: `name, vmid, tag · is:running · type:lxc`.
- No match: empty table, and the frame says "0 of M"; action keys do nothing.
### Code
- Pure function `guest_matches(guest: dict, query: str) -> bool` in `app.py` (or
`cli.py`-free module-level helper) so it can be tested without the UI.
+88 -7
View File
@@ -23,6 +23,7 @@ from textual.widgets import (
Select, Select,
Static, Static,
) )
from textual.widgets._data_table import RowDoesNotExist
from textual.widgets.option_list import Option from textual.widgets.option_list import Option
from cv4pve_tui import cli 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): class GuestTable(DataTable):
"""The guest table; shows its Enter (row selected -> info screen) in the footer.""" """The guest table; shows its Enter (row selected -> info screen) in the footer."""
BINDINGS = [Binding("enter", "select_cursor", "Info")] # noqa: RUF012 - Textual idiom 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): class GuestApp(App):
BINDINGS = [ # noqa: RUF012 - Textual idiom BINDINGS = [ # noqa: RUF012 - Textual idiom
("s", "act('start')", "Start"), ("s", "act('start')", "Start"),
@@ -1006,9 +1040,12 @@ class GuestApp(App):
("t", "shell", "Shell"), ("t", "shell", "Shell"),
("g", "reload", "Refresh"), ("g", "reload", "Refresh"),
("c", "context", "Context"), ("c", "context", "Context"),
("slash", "filter", "Filter"),
("l", "tasks", "Tasks"), ("l", "tasks", "Tasks"),
("U", "updates", "Updates"), ("U", "updates", "Updates"),
("q", "quit", "Quit"), ("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: def __init__(self) -> None:
@@ -1028,6 +1065,7 @@ class GuestApp(App):
border: round $accent; border-title-style: bold; padding: 0 1; border: round $accent; border-title-style: bold; padding: 0 1;
} }
#guests_section DataTable { height: 1fr; } #guests_section DataTable { height: 1fr; }
#filter { display: none; }
""" """
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
@@ -1035,6 +1073,9 @@ class GuestApp(App):
with Vertical(id="guests_section") as section: with Vertical(id="guests_section") as section:
section.border_title = "Guests" section.border_title = "Guests"
yield GuestTable(id="guests", cursor_type="row") yield GuestTable(id="guests", cursor_type="row")
yield FilterInput(
id="filter", placeholder="name, vmid, tag · is:running · type:lxc"
)
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
@@ -1111,10 +1152,17 @@ class GuestApp(App):
def _update_frame(self) -> None: def _update_frame(self) -> None:
n = len(self.unprotected & {g["vmid"] for g in self.guests.values()}) n = len(self.unprotected & {g["vmid"] for g in self.guests.values()})
count = f" · {n} without backup job" if n else "" 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: if self.package_count:
n = self.package_count n = self.package_count
count += f" · {n} package update{'s' if n != 1 else ''}" 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) @work(thread=True)
def _show_context(self) -> None: def _show_context(self) -> None:
@@ -1150,11 +1198,22 @@ class GuestApp(App):
if gen != self.generation: # listed before a context switch: old cluster if gen != self.generation: # listed before a context switch: old cluster
return return
self.unprotected = unprotected or set() 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} 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, _add_plain(table,
"!" if g["vmid"] in self.unprotected else "", "!" if g["vmid"] in self.unprotected else "",
g["vmid"], g["name"], g["type"], g["node"], g["status"], g["vmid"], g["name"], g["type"], g["node"], g["status"],
@@ -1162,10 +1221,32 @@ class GuestApp(App):
*_percents(g), *_percents(g),
key=str(g["vmid"]), key=str(g["vmid"]),
) # fmt: skip ) # fmt: skip
if current and str(current["vmid"]) in self.guests: if current is not None:
table.move_cursor(row=table.get_row_index(str(current["vmid"]))) try:
table.move_cursor(row=table.get_row_index(current))
except RowDoesNotExist: # gone or filtered out: keep the default cursor
pass
self._update_frame() 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: def _reload(self, acted: dict | None = None) -> None:
"""Runs in a worker thread. `acted` gets its live status: the listing lags.""" """Runs in a worker thread. `acted` gets its live status: the listing lags."""
gen = self.generation gen = self.generation
+364
View File
@@ -9,6 +9,8 @@ import threading
from datetime import datetime from datetime import datetime
import pytest import pytest
from hypothesis import given
from hypothesis import strategies as st
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.widgets import ( from textual.widgets import (
Button, Button,
@@ -3511,3 +3513,365 @@ async def test_cli_not_runnable_is_an_error_not_a_crash(fake):
async with app.run_test(size=SIZE) as pilot: async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot) await settle(app, pilot)
assert any("cv4pve-cli" in m for m in errors(app)) and app.is_running assert any("cv4pve-cli" in m for m in errors(app)) and app.is_running
# --- #16: search and filter in the guest table --------------------------------------------
# spec: docs/plans/guest-tui-mvp.md ("Search and filter in the guest table (issue #16)")
TAGGED_VM = {**VM, "tags": "172.16.41.200;community-script;os"}
TAGGED_CT = {**CT, "tags": "backup;Nightly"}
def fguest(**over):
return {**TAGGED_VM, **over}
@pytest.mark.parametrize(
("query", "expected"),
[
("dock", True), # plain term: name substring
("DOCK", True), # case-insensitive
("108", True), # plain term: vmid
("10", True), # vmid substring
("community", True), # plain term: tag substring
("172.16.41.200", True), # a whole tag
("COMMUNITY-script", True), # tag, case-insensitive
("nomatch", False),
("999", False),
],
)
def test_guest_matches_plain_term_on_name_vmid_tag(query, expected): # plain term
assert appmod.guest_matches(fguest(), query) is expected
def test_guest_matches_terms_are_anded(): # all terms must match
g = fguest()
assert appmod.guest_matches(g, "dock community") is True
assert appmod.guest_matches(g, "dock nomatch") is False
assert appmod.guest_matches(g, "nomatch community") is False
assert appmod.guest_matches(g, "dock 108 os is:running type:vm") is True
@pytest.mark.parametrize(
("status", "query", "expected"),
[
("running", "is:running", True),
("running", "is:stopped", False),
("stopped", "is:stopped", True),
("stopped", "is:running", False),
("running", "IS:Running", True), # case-insensitive
],
)
def test_guest_matches_is_status(status, query, expected): # is:running / is:stopped
assert appmod.guest_matches(fguest(status=status), query) is expected
@pytest.mark.parametrize(
("kind", "query", "expected"),
[
("lxc", "type:lxc", True),
("lxc", "type:ct", True),
("lxc", "type:vm", False),
("lxc", "type:qemu", False),
("qemu", "type:vm", True),
("qemu", "type:qemu", True),
("qemu", "type:lxc", False),
("qemu", "type:ct", False),
("qemu", "TYPE:VM", True), # case-insensitive
],
)
def test_guest_matches_type(kind, query, expected): # type:lxc|ct / type:vm|qemu
assert appmod.guest_matches(fguest(type=kind), query) is expected
@pytest.mark.parametrize("query", ["", " "])
def test_guest_matches_empty_query_matches_all(query): # empty filter = no filter
assert appmod.guest_matches(fguest(), query) is True
assert appmod.guest_matches(TAGGED_CT, query) is True
def test_guest_matches_combines_keyword_and_plain_terms(): # AND across term kinds
assert appmod.guest_matches(TAGGED_CT, "type:ct is:stopped back") is True
assert appmod.guest_matches(TAGGED_CT, "type:ct is:running back") is False
_text = st.text(alphabet="abcdXYZ0123;.-", max_size=8)
_guests = st.fixed_dictionaries(
{
"vmid": st.integers(min_value=100, max_value=999),
"name": _text,
"type": st.sampled_from(["qemu", "lxc"]),
"status": st.sampled_from(["running", "stopped"]),
"tags": _text,
}
)
_terms = st.one_of(
_text.filter(bool),
st.sampled_from(["is:running", "is:stopped", "type:lxc", "type:vm", "type:ct"]),
)
@given(g=_guests, terms=st.lists(_terms, max_size=4), extra=_terms)
def test_guest_matches_property_empty_matches_and_more_terms_only_narrow(
g, terms, extra
): # empty query matches all; AND is monotone
assert appmod.guest_matches(g, "") is True
base = " ".join(terms)
if not appmod.guest_matches(g, base):
assert not appmod.guest_matches(g, f"{base} {extra}")
# --- UI -------------------------------------------------------------------------------------
def three_guests(fake):
fake.guests = [
TAGGED_VM,
TAGGED_CT,
{**VM, "vmid": 200, "name": "web", "status": "stopped", "tags": "frontend"},
]
def filter_input(app):
return app.query_one("#filter", Input)
async def type_filter(app, pilot, text):
await pilot.press("slash")
await pilot.pause()
for ch in text:
await pilot.press("space" if ch == " " else ch)
await settle(app, pilot)
async def test_slash_shows_and_focuses_the_filter_input(fake): # '/' shows + focuses
three_guests(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert not visible(app, filter_input(app)) # hidden until then
await pilot.press("slash")
await pilot.pause()
assert visible(app, filter_input(app))
assert app.focused is filter_input(app)
assert "is:running" in filter_input(app).placeholder # syntax hint
async def test_typing_filters_rows_live(fake): # table filters as you type
three_guests(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert sorted(rows(app)) == ["100", "108", "200"]
await pilot.press("slash")
await pilot.press("w", "e")
await settle(app, pilot)
assert rows(app) == ["200"]
await pilot.press("b")
await settle(app, pilot)
assert rows(app) == ["200"]
await pilot.press("backspace", "backspace", "backspace")
await settle(app, pilot)
assert sorted(rows(app)) == ["100", "108", "200"]
async def test_filter_keywords_in_the_ui(
fake,
): # is:/type: through the UI, AND of terms
three_guests(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await type_filter(app, pilot, "is:stopped type:vm")
assert rows(app) == ["200"]
async def test_subtitle_shows_filter_text_and_n_of_m(
fake,
): # "· filter: <text> · N of M"
three_guests(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert "filter" not in frame_subtitle(app)
assert " of " not in frame_subtitle(app)
await type_filter(app, pilot, "is:stopped")
sub = frame_subtitle(app)
assert "· filter: is:stopped · 2 of 3" in sub
async def test_subtitle_order_context_filter_then_counts(
fake,
): # context, filter, counts
three_guests(fake)
fake.unprotected = {100}
fake.updates = {"mainframe": many(3)}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await type_filter(app, pilot, "back")
sub = frame_subtitle(app)
assert "mainframe" in sub and "filter: back" in sub
assert "without backup job" in sub and "package updates" in sub
assert (
sub.index("mainframe")
< sub.index("filter: back")
< sub.index("without backup job")
< sub.index("package updates")
)
async def test_enter_keeps_the_filter_and_focuses_the_table(
fake,
): # Enter in the filter
three_guests(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await type_filter(app, pilot, "web")
await pilot.press("enter")
await settle(app, pilot)
assert app.focused is app.query_one("#guests", DataTable)
assert rows(app) == ["200"]
assert "filter: web" in frame_subtitle(app)
assert not isinstance(app.screen, ModalScreen) # Enter did not open guest info
async def test_escape_clears_hides_and_shows_all_rows(fake): # Escape in the filter
three_guests(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await type_filter(app, pilot, "web")
assert rows(app) == ["200"]
await pilot.press("escape")
await settle(app, pilot)
assert sorted(rows(app)) == ["100", "108", "200"]
assert not visible(app, filter_input(app))
assert filter_input(app).value == ""
assert app.focused is app.query_one("#guests", DataTable)
assert "filter" not in frame_subtitle(app)
assert " of " not in frame_subtitle(app)
async def test_action_after_filtering_acts_on_the_visible_guest(
fake,
): # actions: visible row
three_guests(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await type_filter(app, pilot, "web")
await pilot.press("enter")
await settle(app, pilot)
await pilot.press("s")
await settle(app, pilot)
assert fake.actions == [("start", 200)] # not the first, hidden guest (108)
async def test_reload_keeps_the_filter(fake): # reload (g) keeps the filter applied
three_guests(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await type_filter(app, pilot, "web")
await pilot.press("enter")
await settle(app, pilot)
before = fake.list_calls
await pilot.press("g")
await settle(app, pilot)
assert fake.list_calls > before
assert rows(app) == ["200"]
assert "· filter: web · 1 of 3" in frame_subtitle(app)
async def test_action_refresh_keeps_the_filter(
fake,
): # reload after actions keeps the filter
three_guests(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await type_filter(app, pilot, "web")
await pilot.press("enter")
await pilot.press("s")
await settle(app, pilot)
assert rows(app) == ["200"]
async def test_no_match_shows_empty_table_0_of_m_and_actions_do_nothing(
fake,
): # no match
three_guests(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await type_filter(app, pilot, "zzz")
assert rows(app) == []
assert "0 of 3" in frame_subtitle(app)
await pilot.press("enter") # leave the filter, table focused and empty
await settle(app, pilot)
await pilot.press("s", "S", "x", "r", "t")
await settle(app, pilot)
assert fake.actions == []
assert not isinstance(app.screen, ModalScreen)
assert app.is_running
async def test_context_switch_keeps_the_filter_text(
fake,
): # a context switch keeps the filter text
three_guests(fake)
fake.guests_by_context = {
"mainframe-edge": [{**VM, "vmid": 300, "name": "web-edge", "tags": ""},
{**VM, "vmid": 301, "name": "db-edge", "tags": ""}]
} # fmt: skip
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await type_filter(app, pilot, "web")
await pilot.press("enter")
await settle(app, pilot)
await switch_to_edge(app, pilot)
await until(pilot, lambda: rows(app) == ["300"])
assert "filter: web" in frame_subtitle(app)
assert "1 of 2" in frame_subtitle(app)
# --- #16 review findings ----------------------------------------------------------------
@pytest.mark.parametrize("text", ["[/b]", "[/]", "[red]x"])
async def test_markup_in_the_filter_is_shown_literally_and_does_not_crash(fake, text):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await pilot.press("slash")
await pilot.pause()
app.query_one("#filter").value = text
await settle(app, pilot)
assert app.is_running
shown = Content.from_markup(
app.query_one("#guests_section").border_subtitle
).plain
assert f"filter: {text}" in shown
async def test_escape_on_the_table_clears_an_applied_filter(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await type_filter(app, pilot, "is:stopped")
await pilot.press("enter") # filter applied, focus on the table
await settle(app, pilot)
await pilot.press("escape")
await settle(app, pilot)
assert "filter:" not in frame_subtitle(app)
assert app.query_one("#guests", DataTable).row_count == len(fake.guests)
def test_guest_matches_tolerates_none_status_and_type():
g = {"vmid": 1, "name": "x", "status": None, "type": None, "tags": None}
assert appmod.guest_matches(g, "x")
assert not appmod.guest_matches(g, "is:running")
assert not appmod.guest_matches(g, "type:lxc")