Compare commits

...

1 commit

Author SHA1 Message Date
Ubuntu
fdc6493258 gui: add Delivered + Confirmed (acked) columns to consumers page
Surfaces ConsumerInfo.delivered.consumer_seq (total delivered) and
ConsumerInfo.ack_floor.consumer_seq (total acknowledged) as new columns
on the /consumers page between WAITING and CREATED.

Reuses the existing ConsumerInfo objects already fetched — no new NATS
calls. Both fields use safe getattr access and render '—' when None.
Legend updated to clarify these are consumer-level counters, not
end-to-end mesh delivery confirmation.

Bump version 0.14.8 → 0.14.9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 04:31:49 +00:00
4 changed files with 50 additions and 3 deletions

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "central" name = "central"
version = "0.14.8" version = "0.14.9"
requires-python = ">=3.12,<3.13" requires-python = ">=3.12,<3.13"
description = "Data hub spine — adapters, bus, archive." description = "Data hub spine — adapters, bus, archive."
readme = "README.md" readme = "README.md"

View file

@ -2224,6 +2224,8 @@ async def consumers_list(request: Request) -> HTMLResponse:
"num_ack_pending": ci.num_ack_pending, "num_ack_pending": ci.num_ack_pending,
"num_redelivered": ci.num_redelivered, "num_redelivered": ci.num_redelivered,
"num_waiting": ci.num_waiting, "num_waiting": ci.num_waiting,
"delivered": getattr(getattr(ci, "delivered", None), "consumer_seq", None),
"acked": getattr(getattr(ci, "ack_floor", None), "consumer_seq", None),
"created": ci.created, "created": ci.created,
"protected": ci.name.startswith("archive-"), "protected": ci.name.startswith("archive-"),
}) })

View file

@ -25,6 +25,8 @@ has no active subscriber — it is safe to delete if it is not a central-owned c
<th style="text-align: right; padding: 0.25rem 0.5rem;">Ack Pending</th> <th style="text-align: right; padding: 0.25rem 0.5rem;">Ack Pending</th>
<th style="text-align: right; padding: 0.25rem 0.5rem;">Redelivered</th> <th style="text-align: right; padding: 0.25rem 0.5rem;">Redelivered</th>
<th style="text-align: right; padding: 0.25rem 0.5rem;">Waiting</th> <th style="text-align: right; padding: 0.25rem 0.5rem;">Waiting</th>
<th style="text-align: right; padding: 0.25rem 0.5rem;">Delivered</th>
<th style="text-align: right; padding: 0.25rem 0.5rem;">Confirmed</th>
<th style="text-align: left; padding: 0.25rem 0.5rem;">Created</th> <th style="text-align: left; padding: 0.25rem 0.5rem;">Created</th>
<th style="text-align: center; padding: 0.25rem 0.5rem;">Action</th> <th style="text-align: center; padding: 0.25rem 0.5rem;">Action</th>
</tr> </tr>
@ -37,6 +39,8 @@ has no active subscriber — it is safe to delete if it is not a central-owned c
<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_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_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="text-align: right; padding: 0.25rem 0.5rem;">{{ c.num_waiting if c.num_waiting is not none else '—' }}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{ c.delivered if c.delivered is not none else '—' }}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{ c.acked if c.acked is not none else '—' }}</td>
<td style="padding: 0.25rem 0.5rem;">{{ c.created.isoformat() if c.created 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;"> <td style="text-align: center; padding: 0.25rem 0.5rem;">
{% if c.protected %} {% if c.protected %}
@ -64,7 +68,10 @@ has no active subscriber — it is safe to delete if it is not a central-owned c
<p class="muted" style="margin-top: 1rem; font-size: 0.9em;"> <p class="muted" style="margin-top: 1rem; font-size: 0.9em;">
<strong>Legend:</strong> <em>Pending</em> = messages not yet delivered to this consumer; <strong>Legend:</strong> <em>Pending</em> = messages not yet delivered to this consumer;
<em>Ack Pending</em> = delivered but not yet acknowledged; <em>Ack Pending</em> = delivered but not yet acknowledged;
<em>Waiting</em> = active pull requests from a live subscriber. <em>Waiting</em> = active pull requests from a live subscriber;
<em>Delivered</em> = total messages this consumer has received from the stream (lifetime counter);
<em>Confirmed</em> = total messages this consumer has acknowledged (processed) — these are consumer-level
counters (the subscriber got/acked it) and are NOT confirmation that the message reached a mesh device.
A consumer with high <em>Pending</em> and zero <em>Waiting</em> is abandoned — no subscriber A consumer with high <em>Pending</em> and zero <em>Waiting</em> is abandoned — no subscriber
is pulling from it and messages are piling up. is pulling from it and messages are piling up.
Consumers marked <em>central-owned</em> (archive-*) are managed by central and cannot be deleted here. Consumers marked <em>central-owned</em> (archive-*) are managed by central and cannot be deleted here.

View file

@ -2,6 +2,7 @@
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -13,13 +14,16 @@ os.environ.setdefault("CENTRAL_NATS_URL", "nats://localhost:4222")
def _make_consumer_info(name: str, num_pending: int = 0, num_ack_pending: int = 0, def _make_consumer_info(name: str, num_pending: int = 0, num_ack_pending: int = 0,
num_redelivered: int = 0, num_waiting: int = 0): num_redelivered: int = 0, num_waiting: int = 0,
delivered_seq: int = 0, ack_floor_seq: int = 0):
ci = MagicMock() ci = MagicMock()
ci.name = name ci.name = name
ci.num_pending = num_pending ci.num_pending = num_pending
ci.num_ack_pending = num_ack_pending ci.num_ack_pending = num_ack_pending
ci.num_redelivered = num_redelivered ci.num_redelivered = num_redelivered
ci.num_waiting = num_waiting ci.num_waiting = num_waiting
ci.delivered = SimpleNamespace(consumer_seq=delivered_seq)
ci.ack_floor = SimpleNamespace(consumer_seq=ack_floor_seq)
ci.created = datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc) ci.created = datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc)
return ci return ci
@ -204,6 +208,8 @@ class TestConsumersListHtmlRender:
"num_ack_pending": 0, "num_ack_pending": 0,
"num_redelivered": 0, "num_redelivered": 0,
"num_waiting": 0, "num_waiting": 0,
"delivered": 5000,
"acked": 4000,
"created": datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc), "created": datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc),
"protected": False, "protected": False,
}, },
@ -230,6 +236,8 @@ class TestConsumersListHtmlRender:
"num_ack_pending": 0, "num_ack_pending": 0,
"num_redelivered": 0, "num_redelivered": 0,
"num_waiting": 1, "num_waiting": 1,
"delivered": 100,
"acked": 95,
"created": datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc), "created": datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc),
"protected": True, "protected": True,
}, },
@ -254,6 +262,8 @@ class TestConsumersListHtmlRender:
"num_ack_pending": None, "num_ack_pending": None,
"num_redelivered": None, "num_redelivered": None,
"num_waiting": None, "num_waiting": None,
"delivered": None,
"acked": None,
"created": None, "created": None,
"protected": False, "protected": False,
}, },
@ -267,6 +277,34 @@ class TestConsumersListHtmlRender:
# The guarded fallback em dash is rendered instead # The guarded fallback em dash is rendered instead
assert "" in html assert "" in html
def test_delivered_and_confirmed_columns_render(self):
streams = [
{
"stream": "CENTRAL_WX",
"error": None,
"consumers": [
{
"name": "meshai-wx",
"num_pending": 10,
"num_ack_pending": 2,
"num_redelivered": 0,
"num_waiting": 1,
"delivered": 7777,
"acked": 6543,
"created": datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc),
"protected": False,
},
],
},
]
html = self._render(streams)
# Delivered and Confirmed column headers are present
assert "Delivered" in html
assert "Confirmed" in html
# The actual counter values appear in the rendered HTML
assert "7777" in html
assert "6543" in html
class TestConsumersDeleteArchiveGuard: class TestConsumersDeleteArchiveGuard:
"""POST /consumers/{stream}/archive-*/delete must be refused.""" """POST /consumers/{stream}/archive-*/delete must be refused."""