Skip to content

Commit 64743d0

Browse files
JacobCoffeeclaude
andauthored
feat: Phase 3.1 - Add WebSocket support for real-time dashboard updates (#135)
Co-authored-by: Claude <[email protected]>
1 parent 51e4c74 commit 64743d0

File tree

7 files changed

+460
-9
lines changed

7 files changed

+460
-9
lines changed

services/api/src/byte_api/app.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ def create_app() -> Litestar:
3939

4040
dependencies = create_collection_dependencies()
4141

42+
from byte_api.domain.web.controllers.websocket import set_startup_time
43+
4244
return Litestar(
4345
# Handlers
4446
exception_handlers={
@@ -56,7 +58,10 @@ def create_app() -> Litestar:
5658
# Lifecycle
5759
before_send=[log.controller.BeforeSendHandler()],
5860
on_shutdown=[],
59-
on_startup=[lambda: log.configure(log.default_processors)], # type: ignore[arg-type]
61+
on_startup=[
62+
lambda: log.configure(log.default_processors), # type: ignore[arg-type]
63+
set_startup_time,
64+
],
6065
on_app_init=[],
6166
# Other
6267
debug=settings.project.DEBUG,

services/api/src/byte_api/domain/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
system.controllers.health.HealthController,
3030
system.controllers.metrics.MetricsController,
3131
web.controllers.web.WebController,
32+
web.controllers.websocket.dashboard_stream,
3233
guilds.controllers.GuildsController,
3334
]
3435
"""Routes for the application."""

services/api/src/byte_api/domain/web/controllers/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from __future__ import annotations
44

5-
from byte_api.domain.web.controllers import web
5+
from byte_api.domain.web.controllers import web, websocket
66

77
__all__ = [
88
"web",
9+
"websocket",
910
]
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""WebSocket controller for real-time dashboard updates."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import os
7+
from datetime import UTC, datetime
8+
from typing import TYPE_CHECKING
9+
10+
import structlog
11+
from litestar import WebSocket, websocket
12+
from litestar.exceptions import WebSocketDisconnect
13+
from sqlalchemy import func, select
14+
15+
from byte_common.models.guild import Guild
16+
17+
if TYPE_CHECKING:
18+
from sqlalchemy.ext.asyncio import AsyncSession
19+
20+
21+
logger = structlog.get_logger()
22+
23+
__all__ = ("dashboard_stream",)
24+
25+
# Track application startup time for uptime calculation
26+
_startup_time: datetime | None = None
27+
28+
# WebSocket update interval (configurable for testing)
29+
UPDATE_INTERVAL = float(os.getenv("WS_UPDATE_INTERVAL", "5.0"))
30+
31+
32+
def set_startup_time() -> None:
33+
"""Set the application startup time (call from app.on_startup)."""
34+
global _startup_time # noqa: PLW0603
35+
_startup_time = datetime.now(UTC)
36+
logger.info("Application startup time recorded", startup_time=_startup_time.isoformat())
37+
38+
39+
def get_uptime_seconds() -> int:
40+
"""Get application uptime in seconds.
41+
42+
Returns:
43+
int: Uptime in seconds since application start. Returns 0 if startup time not set.
44+
"""
45+
if _startup_time is None:
46+
return 0
47+
delta = datetime.now(UTC) - _startup_time
48+
return int(delta.total_seconds())
49+
50+
51+
async def get_server_count(db_session: AsyncSession) -> int:
52+
"""Get current server/guild count from database.
53+
54+
Args:
55+
db_session: Database session for querying guilds.
56+
57+
Returns:
58+
int: Number of guilds in the database.
59+
"""
60+
result = await db_session.execute(select(func.count()).select_from(Guild))
61+
return result.scalar_one()
62+
63+
64+
@websocket("/ws/dashboard")
65+
async def dashboard_stream(socket: WebSocket, db_session: AsyncSession) -> None:
66+
"""Stream real-time dashboard updates via WebSocket.
67+
68+
Sends JSON updates every 5 seconds containing:
69+
- server_count: Number of guilds
70+
- bot_status: online/offline
71+
- uptime: Seconds since startup
72+
- timestamp: ISO format timestamp
73+
74+
Args:
75+
socket: WebSocket connection.
76+
db_session: Database session injected by Litestar.
77+
"""
78+
await socket.accept()
79+
logger.info("Dashboard WebSocket client connected", client=socket.client)
80+
81+
try:
82+
# Send updates in a loop
83+
while True:
84+
try:
85+
server_count = await get_server_count(db_session)
86+
uptime = get_uptime_seconds()
87+
88+
data = {
89+
"server_count": server_count,
90+
"bot_status": "online",
91+
"uptime": uptime,
92+
"timestamp": datetime.now(UTC).isoformat(),
93+
}
94+
95+
await socket.send_json(data)
96+
logger.debug("Sent dashboard update", data=data)
97+
98+
# Sleep - any send failures will be caught and exit loop
99+
await asyncio.sleep(UPDATE_INTERVAL)
100+
101+
except (WebSocketDisconnect, RuntimeError):
102+
# Client disconnected or connection closed
103+
logger.info("WebSocket client disconnected")
104+
break
105+
106+
except asyncio.CancelledError:
107+
# Task was cancelled (e.g., test cleanup)
108+
logger.info("WebSocket handler cancelled")
109+
raise
110+
except WebSocketDisconnect:
111+
logger.info("Dashboard WebSocket client disconnected", client=socket.client)
112+
except Exception:
113+
logger.exception("WebSocket error occurred")
114+
raise
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
11
@tailwind base;
22
@tailwind components;
33
@tailwind utilities;
4+
5+
/* WebSocket status indicator styles */
6+
.ws-status {
7+
padding: 2px 8px;
8+
border-radius: 4px;
9+
font-size: 0.75rem;
10+
font-weight: 600;
11+
text-transform: uppercase;
12+
}
13+
14+
.ws-status-connected {
15+
background-color: #10b981;
16+
color: white;
17+
}
18+
19+
.ws-status-disconnected {
20+
background-color: #f59e0b;
21+
color: white;
22+
}
23+
24+
.ws-status-error,
25+
.ws-status-failed {
26+
background-color: #ef4444;
27+
color: white;
28+
}

services/api/src/byte_api/domain/web/templates/dashboard.html

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ <h1 class="text-3xl font-bold tracking-tight text-white">Dashboard</h1>
4040
</svg>
4141
</div>
4242
<div class="stat-title text-base-content">Server Count</div>
43-
<div class="stat-value text-primary">1</div>
44-
<div class="stat-desc">100% more than before Byte was born</div>
43+
<div class="stat-value text-primary" id="server-count">-</div>
44+
<div class="stat-desc">Servers connected to Byte</div>
4545
</div>
4646

4747
<div class="stat">
@@ -68,17 +68,23 @@ <h1 class="text-3xl font-bold tracking-tight text-white">Dashboard</h1>
6868

6969
<div class="stat">
7070
<div class="stat-figure text-secondary">
71-
<div class="avatar online">
71+
<div class="avatar" id="bot-avatar">
7272
<div class="w-16 rounded-full">
7373
<img src="static/logo.svg" alt="Byte Logo" />
7474
</div>
7575
</div>
7676
</div>
77-
<div class="stat-title text-base-content">Uptime</div>
78-
<div class="stat-value text-primary">99.99%</div>
79-
<div class="stat-desc text-warning">2 issues in the last 24 hours</div>
77+
<div class="stat-title text-base-content">Bot Status</div>
78+
<div class="stat-value">
79+
<span id="bot-status" class="badge badge-lg">-</span>
80+
</div>
81+
<div class="stat-desc">Uptime: <span id="uptime">-</span></div>
8082
</div>
8183
</div>
84+
<div class="mt-4 text-sm text-center text-base-content/60 dark:text-base-100/40">
85+
Last update: <span id="last-update">-</span>
86+
| WebSocket: <span id="ws-status" class="ws-status">disconnected</span>
87+
</div>
8288
<div class="mt-10 p-4 rounded-lg shadow-2xl bg-base-100/60 dark:bg-neutral/60">
8389
<h1 class="text-5xl font-bold text-base-content dark:text-base-100/80">Activity</h1>
8490
<div class="h-[calc(50vh-10em)] overflow-y-auto">
@@ -487,4 +493,122 @@ <h1 class="text-5xl font-bold text-base-content dark:text-base-100/80">Activity<
487493
</div>
488494
</div>
489495
</main>
490-
{% endblock content %} {% block extrajs %}{% endblock extrajs %}
496+
{% endblock content %}
497+
{% block extrajs %}
498+
<script>
499+
let ws = null;
500+
let reconnectAttempts = 0;
501+
const maxReconnectAttempts = 5;
502+
503+
function connectWebSocket() {
504+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
505+
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
506+
507+
console.log('Connecting to WebSocket:', wsUrl);
508+
ws = new WebSocket(wsUrl);
509+
510+
ws.onopen = () => {
511+
console.log('WebSocket connected');
512+
reconnectAttempts = 0;
513+
updateConnectionStatus('connected');
514+
};
515+
516+
ws.onmessage = (event) => {
517+
const data = JSON.parse(event.data);
518+
console.log('WebSocket message received:', data);
519+
updateDashboard(data);
520+
};
521+
522+
ws.onclose = () => {
523+
console.log('WebSocket closed, attempting reconnect...');
524+
updateConnectionStatus('disconnected');
525+
526+
if (reconnectAttempts < maxReconnectAttempts) {
527+
reconnectAttempts++;
528+
const delay = 3000 * reconnectAttempts;
529+
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);
530+
setTimeout(connectWebSocket, delay);
531+
} else {
532+
console.error('Max reconnection attempts reached');
533+
updateConnectionStatus('failed');
534+
}
535+
};
536+
537+
ws.onerror = (error) => {
538+
console.error('WebSocket error:', error);
539+
updateConnectionStatus('error');
540+
};
541+
}
542+
543+
function updateDashboard(data) {
544+
// Update server count
545+
const serverCountEl = document.getElementById('server-count');
546+
if (serverCountEl) {
547+
serverCountEl.textContent = data.server_count;
548+
}
549+
550+
// Update bot status
551+
const botStatusEl = document.getElementById('bot-status');
552+
const botAvatarEl = document.getElementById('bot-avatar');
553+
if (botStatusEl) {
554+
const isOnline = data.bot_status === 'online';
555+
botStatusEl.textContent = data.bot_status;
556+
botStatusEl.className = isOnline ? 'badge badge-lg badge-success' : 'badge badge-lg badge-error';
557+
558+
// Update avatar online indicator
559+
if (botAvatarEl) {
560+
if (isOnline) {
561+
botAvatarEl.classList.add('online');
562+
} else {
563+
botAvatarEl.classList.remove('online');
564+
}
565+
}
566+
}
567+
568+
// Update uptime
569+
const uptimeEl = document.getElementById('uptime');
570+
if (uptimeEl) {
571+
uptimeEl.textContent = formatUptime(data.uptime);
572+
}
573+
574+
// Update last update timestamp
575+
const timestampEl = document.getElementById('last-update');
576+
if (timestampEl) {
577+
const updateTime = new Date(data.timestamp);
578+
timestampEl.textContent = updateTime.toLocaleTimeString();
579+
}
580+
}
581+
582+
function formatUptime(seconds) {
583+
const days = Math.floor(seconds / 86400);
584+
const hours = Math.floor((seconds % 86400) / 3600);
585+
const minutes = Math.floor((seconds % 3600) / 60);
586+
587+
if (days > 0) {
588+
return `${days}d ${hours}h ${minutes}m`;
589+
} else if (hours > 0) {
590+
return `${hours}h ${minutes}m`;
591+
} else {
592+
return `${minutes}m`;
593+
}
594+
}
595+
596+
function updateConnectionStatus(status) {
597+
const statusEl = document.getElementById('ws-status');
598+
if (statusEl) {
599+
statusEl.textContent = status;
600+
statusEl.className = `ws-status ws-status-${status}`;
601+
}
602+
}
603+
604+
// Connect on page load
605+
document.addEventListener('DOMContentLoaded', connectWebSocket);
606+
607+
// Cleanup on page unload
608+
window.addEventListener('beforeunload', () => {
609+
if (ws) {
610+
ws.close();
611+
}
612+
});
613+
</script>
614+
{% endblock extrajs %}

0 commit comments

Comments
 (0)