gui: fix consumers_info coroutine usage + list-returning test mock + None-guard counts

- routes.py: change `async for ci in js.consumers_info(stream_name)` to
  `for ci in await js.consumers_info(stream_name)` — nats-py 2.14.0
  consumers_info() is a plain coroutine returning list[ConsumerInfo], not
  an async iterable; the old form threw TypeError silently (swallowed by
  except), causing every stream to show "unavailable" and zero consumers.
- test_consumers.py: replace async-generator mock with AsyncMock returning
  a list, matching the real API; also fix inline consumers_info_raising in
  the error test (remove dead yield); add explicit regression guard asserting
  consumer names appear in the template context.
- consumers_list.html: guard num_pending/num_ack_pending/num_redelivered/
  num_waiting with `… if … is not none else '—'` to prevent "None" in cells.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-28 23:07:35 +00:00
commit 40ea23e904
3 changed files with 18 additions and 15 deletions

View file

@ -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,

View file

@ -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 %}
<tr>
<td style="padding: 0.25rem 0.5rem;">{{ c.name }}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{ c.num_pending }}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{ c.num_ack_pending }}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{ c.num_redelivered }}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{ c.num_waiting }}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{ c.num_pending if c.num_pending is not none else '—' }}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{ c.num_ack_pending if c.num_ack_pending is not none else '—' }}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{ c.num_redelivered if c.num_redelivered is not none else '—' }}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{ c.num_waiting if c.num_waiting is not none else '—' }}</td>
<td style="padding: 0.25rem 0.5rem;">{{ c.created.isoformat() if c.created else '—' }}</td>
<td style="text-align: center; padding: 0.25rem 0.5rem;">
{% if c.protected %}

View file

@ -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