fix: separate bot vs viewer WebSocket connections, add client identification
The dashboard's own WS connection was being counted as a bot subscriber,
causing "1 bot connected" with no bots actually present. Now WS clients
send a role ("bot" or "viewer") in the subscribe message. Only bots count
toward the subscriber total. Bot plugins also send a configurable client_id
so the dashboard shows which specific bots are connected.
Made-with: Cursor
This commit is contained in:
@@ -128,7 +128,13 @@ def create_dashboard_router(
|
||||
await websocket.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
|
||||
manager.add_subscriber(websocket)
|
||||
role = auth_msg.get("role", "bot")
|
||||
client_id = auth_msg.get("client_id", "")
|
||||
remote_addr = ""
|
||||
if websocket.client:
|
||||
remote_addr = websocket.client.host or ""
|
||||
|
||||
manager.add_client(websocket, role=role, client_id=client_id, remote_addr=remote_addr)
|
||||
await manager.broadcast_status()
|
||||
try:
|
||||
while True:
|
||||
@@ -138,7 +144,7 @@ def create_dashboard_router(
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.remove_subscriber(websocket)
|
||||
manager.remove_client(websocket)
|
||||
try:
|
||||
await manager.broadcast_status()
|
||||
except Exception:
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
.toast.error { background: #e53935; }
|
||||
details summary h3 { display: inline; cursor: pointer; }
|
||||
.track-num { width: 40px; text-align: center; }
|
||||
.client-tag {
|
||||
display: inline-block; padding: 2px 8px; margin: 2px 4px;
|
||||
border-radius: 4px; background: #2a2a2a; font-size: 0.8rem;
|
||||
}
|
||||
#client-detail { display: none; margin-top: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -42,6 +47,7 @@
|
||||
<span class="status-dot disconnected" id="status-dot"></span>
|
||||
<strong>NtR Playlist Dashboard</strong>
|
||||
<small id="sub-count">(connecting...)</small>
|
||||
<div id="client-detail"></div>
|
||||
</div>
|
||||
<a href="/logout" role="button" class="outline secondary">Logout</a>
|
||||
</nav>
|
||||
@@ -161,16 +167,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(count) {
|
||||
function updateStatus(count, clients) {
|
||||
subscriberCount = count;
|
||||
const dot = document.getElementById("status-dot");
|
||||
const sub = document.getElementById("sub-count");
|
||||
const detail = document.getElementById("client-detail");
|
||||
if (count > 0) {
|
||||
dot.className = "status-dot connected";
|
||||
sub.textContent = `(${count} bot${count > 1 ? "s" : ""} connected)`;
|
||||
if (clients && clients.length > 0) {
|
||||
detail.innerHTML = clients.map(c => {
|
||||
const name = esc(c.client_id || "unknown");
|
||||
const addr = esc(c.remote_addr || "?");
|
||||
return `<span class="client-tag">${name} (${addr})</span>`;
|
||||
}).join(" ");
|
||||
detail.style.display = "block";
|
||||
} else {
|
||||
detail.style.display = "none";
|
||||
}
|
||||
} else {
|
||||
dot.className = "status-dot disconnected";
|
||||
sub.textContent = "(no bots connected)";
|
||||
detail.style.display = "none";
|
||||
}
|
||||
document.querySelectorAll(".announce-btn").forEach(btn => {
|
||||
if (count === 0) {
|
||||
@@ -188,19 +206,19 @@
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/announce`);
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({type: "subscribe", token: WS_TOKEN}));
|
||||
ws.send(JSON.stringify({type: "subscribe", token: WS_TOKEN, role: "viewer"}));
|
||||
wsBackoff = 1000;
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === "status") {
|
||||
updateStatus(data.subscribers);
|
||||
updateStatus(data.subscribers, data.clients || []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
updateStatus(0);
|
||||
updateStatus(0, []);
|
||||
document.getElementById("sub-count").textContent = "(reconnecting...)";
|
||||
setTimeout(connectWS, wsBackoff);
|
||||
wsBackoff = Math.min(wsBackoff * 2, 60000);
|
||||
|
||||
@@ -1,37 +1,74 @@
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Client:
|
||||
websocket: object
|
||||
role: str
|
||||
client_id: str = ""
|
||||
remote_addr: str = ""
|
||||
connected_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
|
||||
class AnnounceManager:
|
||||
def __init__(self):
|
||||
self._subscribers: list = []
|
||||
self._clients: list[Client] = []
|
||||
|
||||
@property
|
||||
def subscriber_count(self) -> int:
|
||||
return len(self._subscribers)
|
||||
def bot_count(self) -> int:
|
||||
return sum(1 for c in self._clients if c.role == "bot")
|
||||
|
||||
def add_subscriber(self, websocket) -> None:
|
||||
self._subscribers.append(websocket)
|
||||
logger.info("Subscriber connected (%d total)", self.subscriber_count)
|
||||
@property
|
||||
def bot_clients(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"client_id": c.client_id,
|
||||
"remote_addr": c.remote_addr,
|
||||
"connected_at": c.connected_at,
|
||||
}
|
||||
for c in self._clients
|
||||
if c.role == "bot"
|
||||
]
|
||||
|
||||
def remove_subscriber(self, websocket) -> None:
|
||||
self._subscribers = [ws for ws in self._subscribers if ws is not websocket]
|
||||
logger.info("Subscriber disconnected (%d total)", self.subscriber_count)
|
||||
def add_client(self, websocket, role: str, client_id: str = "", remote_addr: str = "") -> None:
|
||||
self._clients.append(Client(
|
||||
websocket=websocket,
|
||||
role=role,
|
||||
client_id=client_id,
|
||||
remote_addr=remote_addr,
|
||||
))
|
||||
logger.info(
|
||||
"Client connected: role=%s client_id=%s addr=%s (%d bots, %d total)",
|
||||
role, client_id, remote_addr, self.bot_count, len(self._clients),
|
||||
)
|
||||
|
||||
def remove_client(self, websocket) -> None:
|
||||
removed = [c for c in self._clients if c.websocket is websocket]
|
||||
self._clients = [c for c in self._clients if c.websocket is not websocket]
|
||||
for c in removed:
|
||||
logger.info(
|
||||
"Client disconnected: role=%s client_id=%s (%d bots, %d total)",
|
||||
c.role, c.client_id, self.bot_count, len(self._clients),
|
||||
)
|
||||
|
||||
async def broadcast(self, message: dict) -> None:
|
||||
dead = []
|
||||
for ws in self._subscribers:
|
||||
for client in self._clients:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
await client.websocket.send_json(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
logger.warning("Removing dead subscriber")
|
||||
dead.append(client.websocket)
|
||||
logger.warning("Removing dead client: %s", client.client_id or client.role)
|
||||
for ws in dead:
|
||||
self.remove_subscriber(ws)
|
||||
self.remove_client(ws)
|
||||
|
||||
async def broadcast_status(self) -> None:
|
||||
await self.broadcast({
|
||||
"type": "status",
|
||||
"subscribers": self.subscriber_count,
|
||||
"subscribers": self.bot_count,
|
||||
"clients": self.bot_clients,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user