Show DSH alarm hub multi-subscriber status instead of OClaw single link.

Track per-connection metadata on the hub and surface it in the UME key-alert UI so operators can see which Netx Ops clients are subscribed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 16:57:42 +08:00
parent 0f8a06bfba
commit 5096117ad4
8 changed files with 328 additions and 46 deletions

View file

@ -10,6 +10,8 @@ from __future__ import annotations
import asyncio
import logging
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@ -24,7 +26,7 @@ from .db import SessionLocal
_log = logging.getLogger("netx.dsh.alarm_hub")
_LOCK = threading.Lock()
_CLIENTS: set[WebSocket] = set()
_CLIENTS: dict[WebSocket, "SubscriberInfo"] = {}
_LOOP: asyncio.AbstractEventLoop | None = None
_STATS = {
"published": 0,
@ -34,10 +36,33 @@ _STATS = {
}
@dataclass
class SubscriberInfo:
"""One authenticated netxops (or other DSH) subscriber."""
id: str
user: str
remote: str = ""
client: str = ""
connected_at: str = field(default_factory=lambda: _utc_now_iso())
last_seen_at: str = field(default_factory=lambda: _utc_now_iso())
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _client_remote(websocket: WebSocket) -> str:
client = getattr(websocket, "client", None)
if client is None:
return ""
host = getattr(client, "host", None) or ""
port = getattr(client, "port", None)
if host and port is not None:
return f"{host}:{port}"
return str(host or "")
def bind_event_loop(loop: asyncio.AbstractEventLoop | None) -> None:
"""Remember the API process loop so sync publishers can schedule sends."""
global _LOOP
@ -51,16 +76,28 @@ def subscriber_count() -> int:
def hub_status() -> dict[str, Any]:
with _LOCK:
clients = len(_CLIENTS)
clients = list(_CLIENTS.values())
stats = dict(_STATS)
stats["subscribers"] = clients
stats["subscribers"] = len(clients)
connections = [
{
"id": info.id,
"user": info.user,
"remote": info.remote,
"client": info.client,
"connected_at": info.connected_at,
"last_seen_at": info.last_seen_at,
}
for info in sorted(clients, key=lambda x: x.connected_at)
]
return {
"enabled": True,
"path": "/v1/integrations/dsh-alarm/ws",
"subscribers": clients,
"subscribers": len(connections),
"published": int(stats.get("published") or 0),
"deliver_ok": int(stats.get("deliver_ok") or 0),
"deliver_fail": int(stats.get("deliver_fail") or 0),
"connections": connections,
}
@ -96,9 +133,19 @@ async def _send_json(ws: WebSocket, payload: dict[str, Any]) -> bool:
return False
def _drop_clients(dead: list[WebSocket]) -> None:
if not dead:
return
with _LOCK:
for ws in dead:
_CLIENTS.pop(ws, None)
_STATS["subscribers"] = len(_CLIENTS)
_STATS["deliver_fail"] += len(dead)
async def _broadcast(payload: dict[str, Any]) -> int:
with _LOCK:
clients = list(_CLIENTS)
clients = list(_CLIENTS.keys())
if not clients:
return 0
envelope = {
@ -115,11 +162,7 @@ async def _broadcast(payload: dict[str, Any]) -> int:
else:
dead.append(ws)
if dead:
with _LOCK:
for ws in dead:
_CLIENTS.discard(ws)
_STATS["subscribers"] = len(_CLIENTS)
_STATS["deliver_fail"] += len(dead)
_drop_clients(dead)
for ws in dead:
try:
await ws.close()
@ -161,6 +204,7 @@ async def dsh_alarm_ws_loop(websocket: WebSocket) -> None:
await websocket.accept()
bind_event_loop(asyncio.get_running_loop())
authed = False
info: SubscriberInfo | None = None
try:
while True:
raw = await websocket.receive_text()
@ -185,20 +229,41 @@ async def dsh_alarm_ws_loop(websocket: WebSocket) -> None:
await _send_json(websocket, {"type": "auth-fail", "error": detail})
await websocket.close(code=4401)
return
client_label = str(
msg.get("client") or msg.get("host") or msg.get("client_id") or ""
).strip()[:120]
now = _utc_now_iso()
info = SubscriberInfo(
id=uuid.uuid4().hex[:12],
user=detail,
remote=_client_remote(websocket),
client=client_label,
connected_at=now,
last_seen_at=now,
)
authed = True
with _LOCK:
_CLIENTS.add(websocket)
_CLIENTS[websocket] = info
_STATS["subscribers"] = len(_CLIENTS)
await _send_json(
websocket,
{
"type": "auth-ok",
"user": detail,
"ts": _utc_now_iso(),
"connection_id": info.id,
"ts": now,
},
)
_log.info("dsh alarm hub subscriber connected (%s)", detail)
_log.info(
"dsh alarm hub subscriber connected id=%s user=%s remote=%s client=%s",
info.id,
detail,
info.remote,
info.client or "-",
)
continue
if info is not None:
info.last_seen_at = _utc_now_iso()
if mtype == "ping":
await _send_json(websocket, {"type": "pong", "ts": _utc_now_iso()})
continue
@ -207,6 +272,10 @@ async def dsh_alarm_ws_loop(websocket: WebSocket) -> None:
return
finally:
with _LOCK:
_CLIENTS.discard(websocket)
_CLIENTS.pop(websocket, None)
_STATS["subscribers"] = len(_CLIENTS)
_log.info("dsh alarm hub subscriber disconnected")
_log.info(
"dsh alarm hub subscriber disconnected id=%s user=%s",
info.id if info else "-",
info.user if info else "-",
)

View file

@ -34,6 +34,7 @@ from .models import (
UmeKeyAlertRule,
UmeSyncJob,
)
from .dsh_alarm_hub import hub_status as dsh_alarm_hub_status
from .oclaw_alarm_forwarder import (
forwarder_status,
request_forwarder_reconnect,
@ -146,7 +147,15 @@ def ume_list_key_alert_rules(
for row in rows
]
fwd = forwarder_status()
return {"items": items, "total": total, "page": page, "page_size": page_size, "forwarder": fwd}
hub = dsh_alarm_hub_status()
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"forwarder": fwd,
"dsh_alarm_hub": hub,
}
@router.get("/v1/ume/key-alert-monitor")
@ -173,6 +182,8 @@ def ume_key_alert_monitor(
"page": int(base.get("page") or page),
"page_size": int(base.get("page_size") or page_size),
"config": get_key_alert_monitor_config(db),
"dsh_alarm_hub": base.get("dsh_alarm_hub") or dsh_alarm_hub_status(),
# Legacy OClaw outbound bridge (optional; demoted in UI).
"forwarder": base.get("forwarder") or forwarder_status(),
}