Skip to content

Commit 3999c46

Browse files
committed
Implement server-side host search in the Livestatus query
CMK-35350 Change-Id: Ia8831abc20635a77d472719954ecaa4c38aebd33
1 parent f58c69f commit 3999c46

5 files changed

Lines changed: 84 additions & 23 deletions

File tree

cmk/gui/monitor/hosts/_api/_list_hosts.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,16 +127,18 @@ def list_hosts(limit: Limit = 1000, sort: Sort = ApiOmitted(), q: Search = "") -
127127
"""List hosts to be consumed by the all host monitoring page."""
128128
user.need_permission("general.see_all")
129129

130-
# ``sort`` and ``q`` are validated to expose the parameters in the API spec; applying them is
131-
# done by the host handlers and wired up there separately.
130+
# ``sort`` is validated to expose the parameter in the API spec; applying it is done by the
131+
# host handlers and wired up there separately.
132132
host_repo = LiveStatusHostRepository(connection=sites.live())
133133

134-
return _handle_list_hosts(host_repo, limit=limit)
134+
return _handle_list_hosts(host_repo, limit=limit, search_query=q)
135135

136136

137-
def _handle_list_hosts(host_repo: HostRepository, *, limit: int) -> HostsResponse:
138-
hosts = host_repo.fetch(limit=limit)
139-
host_total = host_repo.count()
137+
def _handle_list_hosts(
138+
host_repo: HostRepository, *, limit: int, search_query: str = ""
139+
) -> HostsResponse:
140+
hosts = host_repo.fetch(limit=limit, search_query=search_query)
141+
host_total = host_repo.count(search_query=search_query)
140142

141143
return HostsResponse(
142144
hosts=[HostEntry.from_domain(host) for host in hosts],

cmk/gui/monitor/hosts/_impl.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,31 @@
1313
from collections.abc import Sequence
1414

1515
from cmk.livestatus_client import MultiSiteConnection
16+
from cmk.livestatus_client.expressions import NothingExpression, Or, QueryExpression
1617
from cmk.livestatus_client.queries import detailed_connection, Query
1718
from cmk.livestatus_client.tables import Hosts, Status
1819

1920
from ._models import Host, HostState, ServiceCounts
2021

22+
_SEARCHABLE_COLUMNS = (Hosts.name, Hosts.alias, Hosts.address)
23+
24+
25+
def _search_filter(search_query: str) -> QueryExpression:
26+
"""Build an OR-combined case-insensitive "contains" filter over the searchable columns.
27+
28+
``search_query`` is expected to be already whitespace-stripped. An empty value yields a no-op
29+
filter, so the resulting query is identical to one without a search.
30+
"""
31+
if not search_query:
32+
return NothingExpression()
33+
return Or(*(column.contains(search_query, ignore_case=True) for column in _SEARCHABLE_COLUMNS))
34+
2135

2236
class LiveStatusHostRepository:
2337
def __init__(self, *, connection: MultiSiteConnection) -> None:
2438
self._connection = connection
2539

26-
def fetch(self, *, limit: int) -> Sequence[Host]:
40+
def fetch(self, *, limit: int, search_query: str = "") -> Sequence[Host]:
2741
q = Query(
2842
[
2943
Hosts.name,
@@ -37,6 +51,7 @@ def fetch(self, *, limit: int) -> Sequence[Host]:
3751
Hosts.num_services_unknown,
3852
Hosts.num_services_pending,
3953
],
54+
_search_filter(search_query),
4055
extra_headers=[
4156
f"Limit: {limit}",
4257
],
@@ -62,8 +77,17 @@ def fetch(self, *, limit: int) -> Sequence[Host]:
6277
for row in q.iterate(conn)
6378
]
6479

65-
def count(self) -> int:
66-
q = Query([Status.num_hosts])
80+
def count(self, *, search_query: str = "") -> int:
81+
if not search_query:
82+
q = Query([Status.num_hosts])
83+
with detailed_connection(self._connection) as conn:
84+
return sum(row["num_hosts"] for row in q.iterate(conn))
6785

68-
with detailed_connection(self._connection) as conn:
69-
return sum(row["num_hosts"] for row in q.iterate(conn))
86+
# A filtered total can't be read from the ``status`` table. Count the matches server-side
87+
# via ``Stats`` instead of transferring and counting every matching row. The ``Query`` class
88+
# can't emit ``Stats`` headers yet, so the query is assembled by hand from the shared filter.
89+
# The ``Stats`` count is the trailing column of each returned row; summing it across rows
90+
# adds up the per-site counts.
91+
filter_lines = (": ".join(line) for line in _search_filter(search_query).render())
92+
stats_query = "\n".join([f"GET {Hosts.__tablename__}", "Stats: state >= 0", *filter_lines])
93+
return sum(int(row[-1]) for row in self._connection.query(stats_query))

cmk/gui/monitor/hosts/_repositories.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,18 @@
1717

1818

1919
class HostRepository(Protocol):
20-
def fetch(self, *, limit: int) -> Sequence[Host]:
21-
"""Fetch hosts based on filter criteria."""
20+
def fetch(self, *, limit: int, search_query: str = "") -> Sequence[Host]:
21+
"""Fetch hosts based on filter criteria.
22+
23+
``search_query`` is an already whitespace-stripped free-text search. When empty, no search
24+
filter is applied.
25+
"""
2226
...
2327

24-
def count(self) -> int:
25-
"""Count the total number of hosts."""
28+
def count(self, *, search_query: str = "") -> int:
29+
"""Count the hosts matching the given criteria.
30+
31+
``search_query`` is an already whitespace-stripped free-text search. When empty, the total
32+
number of hosts is returned.
33+
"""
2634
...

tests/openapi/test_openapi_monitor_all_hosts.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,6 @@ def test_blank_search_is_treated_as_no_filter(
173173
assert len(resp.json["hosts"]) == 3
174174
assert resp.json["meta"]["total"] == 3
175175

176-
@pytest.mark.xfail(strict=True, reason="server-side host search is wired up in CMK-35350")
177176
def test_search_filters_hosts_and_total(
178177
self,
179178
clients: ClientRegistry,
@@ -187,7 +186,6 @@ def test_search_filters_hosts_and_total(
187186
assert [host["name"] for host in resp.json["hosts"]] == ["heute"]
188187
assert resp.json["meta"]["total"] == 1
189188

190-
@pytest.mark.xfail(strict=True, reason="server-side host search is wired up in CMK-35350")
191189
def test_search_with_no_matches(
192190
self,
193191
clients: ClientRegistry,
@@ -221,7 +219,7 @@ def _setup_search(self, mock_livestatus: MockLiveStatusConnection, *, query: str
221219
mock_livestatus.expect_query(
222220
[
223221
"GET hosts",
224-
"Columns: name",
222+
"Stats: state >= 0",
225223
*search_filter,
226224
]
227225
)

tests/unit/cmk/gui/monitor/hosts/test_list_hosts.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,23 @@ class HostFakeRepository:
2121
def __init__(self) -> None:
2222
self._hosts = [HostFactory.build() for _ in range(n_hosts)]
2323

24-
def fetch(self, *, limit: int) -> Sequence[Host]:
25-
return self._hosts[:limit]
26-
27-
def count(self) -> int:
28-
return len(self._hosts)
24+
def fetch(self, *, limit: int, search_query: str = "") -> Sequence[Host]:
25+
return self._matching(search_query)[:limit]
26+
27+
def count(self, *, search_query: str = "") -> int:
28+
return len(self._matching(search_query))
29+
30+
def _matching(self, search_query: str) -> list[Host]:
31+
if not search_query:
32+
return self._hosts
33+
needle = search_query.lower()
34+
return [
35+
host
36+
for host in self._hosts
37+
if needle in host.name.lower()
38+
or needle in host.alias.lower()
39+
or needle in host.ip.lower()
40+
]
2941

3042
return HostFakeRepository()
3143

@@ -45,3 +57,20 @@ def test_handle_list_hosts_state_label_conversion() -> None:
4557
host_states = [host.state for host in response.hosts]
4658

4759
assert all(state in {"UP", "DOWN", "UNREACHABLE"} for state in host_states)
60+
61+
62+
def test_handle_list_hosts_forwards_search_query_to_repository() -> None:
63+
calls: dict[str, str] = {}
64+
65+
class RecordingRepository:
66+
def fetch(self, *, limit: int, search_query: str = "") -> Sequence[Host]:
67+
calls["fetch"] = search_query
68+
return []
69+
70+
def count(self, *, search_query: str = "") -> int:
71+
calls["count"] = search_query
72+
return 0
73+
74+
_handle_list_hosts(RecordingRepository(), limit=10, search_query="web")
75+
76+
assert calls == {"fetch": "web", "count": "web"}

0 commit comments

Comments
 (0)