mirror of
https://github.com/zvx-echo6/central.git
synced 2026-08-26 17:31:39 +00:00
gui: add Delivered + Confirmed (acked) columns to consumers page (#119)
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: Ubuntu <zvx@cortex.echo6.co> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a84ac0f911
commit
9450696fe9
4 changed files with 50 additions and 3 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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-"),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue