mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-22 02:24:38 +02:00
feat(gui): add read-only dashboard with HTMX polling
- Add NATS connection module (nats.py) for JetStream access - Add three dashboard cards: events (24h), stream sizes, poll times - Replace placeholder index with HTMX-polling dashboard - Graceful degradation when NATS unavailable (200 with error, not 500) - Per-stream/adapter failure isolation - Add comprehensive dashboard tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c31de2499d
commit
736b637d31
8 changed files with 473 additions and 7 deletions
|
|
@ -80,12 +80,16 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
from central.bootstrap_config import get_settings
|
||||
from central.gui.db import close_pool, init_pool
|
||||
from central.gui.nats import close_nats, init_nats
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Initialize database pool
|
||||
await init_pool(settings.db_dsn)
|
||||
|
||||
# Initialize NATS connection
|
||||
await init_nats(settings.nats_url)
|
||||
|
||||
# Start session cleanup task
|
||||
_shutdown_event = asyncio.Event()
|
||||
_cleanup_task = asyncio.create_task(_session_cleanup_loop())
|
||||
|
|
@ -103,6 +107,7 @@ async def lifespan(app: FastAPI):
|
|||
except asyncio.TimeoutError:
|
||||
_cleanup_task.cancel()
|
||||
|
||||
await close_nats()
|
||||
await close_pool()
|
||||
logger.info("Central GUI stopped")
|
||||
|
||||
|
|
|
|||
46
src/central/gui/nats.py
Normal file
46
src/central/gui/nats.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""NATS connection for GUI."""
|
||||
|
||||
import logging
|
||||
|
||||
import nats
|
||||
from nats.js import JetStreamContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_nc: nats.NATS | None = None
|
||||
_js: JetStreamContext | None = None
|
||||
|
||||
|
||||
async def init_nats(url: str) -> JetStreamContext | None:
|
||||
"""Initialize the NATS connection and JetStream context."""
|
||||
global _nc, _js
|
||||
if _nc is None:
|
||||
try:
|
||||
_nc = await nats.connect(url)
|
||||
_js = _nc.jetstream()
|
||||
logger.info("Connected to NATS", extra={"url": url})
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to NATS", extra={"error": str(e)})
|
||||
_nc = None
|
||||
_js = None
|
||||
return _js
|
||||
|
||||
|
||||
def get_js() -> JetStreamContext | None:
|
||||
"""Get the JetStream context. Returns None if not connected."""
|
||||
return _js
|
||||
|
||||
|
||||
async def close_nats() -> None:
|
||||
"""Close the NATS connection."""
|
||||
global _nc, _js
|
||||
if _nc is not None:
|
||||
try:
|
||||
await _nc.drain()
|
||||
await _nc.close()
|
||||
logger.info("Disconnected from NATS")
|
||||
except Exception as e:
|
||||
logger.warning("Error closing NATS", extra={"error": str(e)})
|
||||
finally:
|
||||
_nc = None
|
||||
_js = None
|
||||
|
|
@ -23,6 +23,9 @@ from central.gui.db import get_pool
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
# Streams to display on dashboard
|
||||
DASHBOARD_STREAMS = ["CENTRAL_WX", "CENTRAL_FIRE", "CENTRAL_QUAKE", "CENTRAL_META"]
|
||||
|
||||
|
||||
def _get_templates():
|
||||
"""Get templates instance (deferred import to avoid circular)."""
|
||||
|
|
@ -30,6 +33,15 @@ def _get_templates():
|
|||
return templates
|
||||
|
||||
|
||||
def _format_bytes(size: int) -> str:
|
||||
"""Format bytes as human-readable string."""
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if size < 1024:
|
||||
return f"{size:.1f} {unit}" if unit != "B" else f"{size} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} PB"
|
||||
|
||||
|
||||
def _set_session_cookie(
|
||||
response: Response,
|
||||
token: str,
|
||||
|
|
@ -76,6 +88,155 @@ async def index(request: Request, csrf_protect: CsrfProtect = Depends()) -> HTML
|
|||
return response
|
||||
|
||||
|
||||
@router.get("/dashboard/events", response_class=HTMLResponse)
|
||||
async def dashboard_events(request: Request) -> HTMLResponse:
|
||||
"""Get events by adapter for the last 24 hours."""
|
||||
templates = _get_templates()
|
||||
pool = get_pool()
|
||||
|
||||
events = []
|
||||
error = None
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT adapter, COUNT(*) as count
|
||||
FROM events
|
||||
WHERE received > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY adapter
|
||||
ORDER BY count DESC
|
||||
"""
|
||||
)
|
||||
events = [{"adapter": row["adapter"], "count": row["count"]} for row in rows]
|
||||
except Exception as e:
|
||||
error = f"Database error: {str(e)}"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="_dashboard_events.html",
|
||||
context={"events": events, "error": error},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/streams", response_class=HTMLResponse)
|
||||
async def dashboard_streams(request: Request) -> HTMLResponse:
|
||||
"""Get stream sizes from NATS JetStream."""
|
||||
from central.gui.nats import get_js
|
||||
|
||||
templates = _get_templates()
|
||||
js = get_js()
|
||||
|
||||
streams = None
|
||||
error = None
|
||||
|
||||
if js is None:
|
||||
error = "NATS unavailable"
|
||||
else:
|
||||
streams = []
|
||||
for stream_name in DASHBOARD_STREAMS:
|
||||
try:
|
||||
info = await js.stream_info(stream_name)
|
||||
streams.append({
|
||||
"name": stream_name,
|
||||
"messages": info.state.messages,
|
||||
"size": _format_bytes(info.state.bytes),
|
||||
"error": None,
|
||||
})
|
||||
except Exception:
|
||||
streams.append({
|
||||
"name": stream_name,
|
||||
"messages": 0,
|
||||
"size": "0 B",
|
||||
"error": "unavailable",
|
||||
})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="_dashboard_streams.html",
|
||||
context={"streams": streams, "error": error},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/polls", response_class=HTMLResponse)
|
||||
async def dashboard_polls(request: Request) -> HTMLResponse:
|
||||
"""Get last poll times for each adapter."""
|
||||
from central.gui.nats import get_js
|
||||
|
||||
templates = _get_templates()
|
||||
pool = get_pool()
|
||||
js = get_js()
|
||||
|
||||
adapters = []
|
||||
error = None
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT name FROM config.adapters ORDER BY name"
|
||||
)
|
||||
adapter_names = [row["name"] for row in rows]
|
||||
except Exception as e:
|
||||
error = f"Database error: {str(e)}"
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="_dashboard_polls.html",
|
||||
context={"adapters": [], "error": error},
|
||||
)
|
||||
|
||||
if js is None:
|
||||
error = "NATS unavailable"
|
||||
adapters = [{"name": name, "last_poll": None, "status": None, "error": "NATS unavailable"} for name in adapter_names]
|
||||
else:
|
||||
for name in adapter_names:
|
||||
try:
|
||||
# Get last message from CENTRAL_META for this adapter
|
||||
sub = await js.pull_subscribe(
|
||||
f"central.meta.{name}.status",
|
||||
durable=f"dashboard-poll-{name}",
|
||||
stream="CENTRAL_META",
|
||||
)
|
||||
try:
|
||||
msgs = await sub.fetch(1, timeout=1.0)
|
||||
if msgs:
|
||||
import json
|
||||
data = json.loads(msgs[0].data.decode())
|
||||
last_poll = data.get("data", {}).get("time", "—")
|
||||
adapters.append({
|
||||
"name": name,
|
||||
"last_poll": last_poll,
|
||||
"status": "✓",
|
||||
"error": None,
|
||||
})
|
||||
else:
|
||||
adapters.append({
|
||||
"name": name,
|
||||
"last_poll": None,
|
||||
"status": None,
|
||||
"error": None,
|
||||
})
|
||||
except Exception:
|
||||
adapters.append({
|
||||
"name": name,
|
||||
"last_poll": None,
|
||||
"status": None,
|
||||
"error": None,
|
||||
})
|
||||
except Exception:
|
||||
adapters.append({
|
||||
"name": name,
|
||||
"last_poll": None,
|
||||
"status": None,
|
||||
"error": "unavailable",
|
||||
})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="_dashboard_polls.html",
|
||||
context={"adapters": adapters, "error": error},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/setup", response_class=HTMLResponse)
|
||||
async def setup_form(
|
||||
request: Request,
|
||||
|
|
|
|||
22
src/central/gui/templates/_dashboard_events.html
Normal file
22
src/central/gui/templates/_dashboard_events.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% elif events %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Adapter</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td>{{ event.adapter }}</td>
|
||||
<td>{{ event.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No events in the last 24 hours.</p>
|
||||
{% endif %}
|
||||
31
src/central/gui/templates/_dashboard_polls.html
Normal file
31
src/central/gui/templates/_dashboard_polls.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% elif adapters %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Adapter</th>
|
||||
<th>Last Poll</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for adapter in adapters %}
|
||||
<tr>
|
||||
<td>{{ adapter.name }}</td>
|
||||
{% if adapter.error %}
|
||||
<td colspan="2" class="error">{{ adapter.error }}</td>
|
||||
{% elif adapter.last_poll %}
|
||||
<td>{{ adapter.last_poll }}</td>
|
||||
<td>{{ adapter.status }}</td>
|
||||
{% else %}
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No adapter data available.</p>
|
||||
{% endif %}
|
||||
28
src/central/gui/templates/_dashboard_streams.html
Normal file
28
src/central/gui/templates/_dashboard_streams.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% elif streams %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Stream</th>
|
||||
<th>Messages</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for stream in streams %}
|
||||
<tr>
|
||||
<td>{{ stream.name }}</td>
|
||||
{% if stream.error %}
|
||||
<td colspan="2" class="error">{{ stream.error }}</td>
|
||||
{% else %}
|
||||
<td>{{ stream.messages }}</td>
|
||||
<td>{{ stream.size }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No stream data available.</p>
|
||||
{% endif %}
|
||||
|
|
@ -1,12 +1,27 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Central — Coming Soon{% endblock %}
|
||||
{% block title %}Central — Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<header>
|
||||
<h1>Central</h1>
|
||||
</header>
|
||||
<p>Data hub GUI — coming soon.</p>
|
||||
</article>
|
||||
<h1>Dashboard</h1>
|
||||
<div class="grid">
|
||||
<article>
|
||||
<header>Events (24h)</header>
|
||||
<div hx-get="/dashboard/events" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||
Loading...
|
||||
</div>
|
||||
</article>
|
||||
<article>
|
||||
<header>Stream Sizes</header>
|
||||
<div hx-get="/dashboard/streams" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||
Loading...
|
||||
</div>
|
||||
</article>
|
||||
<article>
|
||||
<header>Last Poll Times</header>
|
||||
<div hx-get="/dashboard/polls" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||
Loading...
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue