diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py
index d07230a..4b25fe2 100644
--- a/src/central/gui/routes.py
+++ b/src/central/gui/routes.py
@@ -2217,7 +2217,7 @@ async def consumers_list(request: Request) -> HTMLResponse:
if js is not None:
try:
- async for ci in js.consumers_info(stream_name):
+ for ci in await js.consumers_info(stream_name):
consumers.append({
"name": ci.name,
"num_pending": ci.num_pending,
diff --git a/src/central/gui/templates/consumers_list.html b/src/central/gui/templates/consumers_list.html
index 9370d3b..252c1c4 100644
--- a/src/central/gui/templates/consumers_list.html
+++ b/src/central/gui/templates/consumers_list.html
@@ -32,10 +32,10 @@ has no active subscriber — it is safe to delete if it is not a central-owned c
{% for c in stream.consumers %}
| {{ c.name }} |
- {{ c.num_pending }} |
- {{ c.num_ack_pending }} |
- {{ c.num_redelivered }} |
- {{ c.num_waiting }} |
+ {{ c.num_pending if c.num_pending is not none else '—' }} |
+ {{ c.num_ack_pending if c.num_ack_pending is not none else '—' }} |
+ {{ c.num_redelivered if c.num_redelivered is not none else '—' }} |
+ {{ c.num_waiting if c.num_waiting is not none else '—' }} |
{{ c.created.isoformat() if c.created else '—' }} |
{% if c.protected %}
diff --git a/tests/test_consumers.py b/tests/test_consumers.py
index 84d3c3e..b947b8f 100644
--- a/tests/test_consumers.py
+++ b/tests/test_consumers.py
@@ -25,14 +25,11 @@ def _make_consumer_info(name: str, num_pending: int = 0, num_ack_pending: int =
def _make_js_with_consumers(consumers_by_stream: dict):
- """Build a mock JetStreamContext whose consumers_info is an async generator."""
+ """Build a mock JetStreamContext whose consumers_info is a coroutine returning a list."""
mock_js = MagicMock()
-
- async def consumers_info(stream_name):
- for ci in consumers_by_stream.get(stream_name, []):
- yield ci
-
- mock_js.consumers_info = consumers_info
+ mock_js.consumers_info = AsyncMock(
+ side_effect=lambda stream, **kw: consumers_by_stream.get(stream, [])
+ )
mock_js.consumer_info = AsyncMock()
mock_js.delete_consumer = AsyncMock()
return mock_js
@@ -108,6 +105,13 @@ class TestConsumersListWithConsumers:
assert meshai_c["num_pending"] == 1000
assert meshai_c["num_waiting"] == 0
+ # Regression guard: consumer names must appear in the template context so
+ # they are rendered into the HTML body (guards against the coroutine/iterator
+ # bug where consumers_info was consumed as an async-iterable instead of awaited).
+ consumer_names_in_context = {c["name"] for c in wx["consumers"]}
+ assert "archive-CENTRAL_WX" in consumer_names_in_context
+ assert "meshai-wx" in consumer_names_in_context
+
@pytest.mark.asyncio
async def test_stream_with_no_consumers_shows_empty(self):
from central.gui.routes import consumers_list
@@ -147,9 +151,8 @@ class TestConsumersListWithConsumers:
async def consumers_info_raising(stream_name):
if stream_name == "CENTRAL_FIRE":
raise RuntimeError("stream not found")
- # other streams: empty
- return
- yield # make it an async generator
+ # other streams: empty list (coroutine returning a list, not an async generator)
+ return []
mock_js.consumers_info = consumers_info_raising
|