Skip to content

Commit 9a99927

Browse files
JacobCoffeeclaude
andcommitted
feat: add WebSocket endpoint for dashboard updates
Implements real-time WebSocket streaming at /ws/dashboard with: - Server count from database - Bot status (currently hardcoded to 'online') - Application uptime tracking - JSON updates every 5 seconds - Graceful disconnect handling - Structured logging Helper functions: - set_startup_time(): Records app startup time - get_uptime_seconds(): Calculates uptime - get_server_count(): Queries guild count from DB Includes comprehensive unit tests for WebSocket functionality. Registered in app lifecycle and domain routes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent bcef6a7 commit 9a99927

File tree

7 files changed

+438
-9
lines changed

7 files changed

+438
-9
lines changed

services/api/src/byte_api/app.py

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

3939
dependencies = create_collection_dependencies()
4040

41+
from byte_api.domain.web.controllers.websocket import set_startup_time
42+
4143
return Litestar(
4244
# Handlers
4345
exception_handlers={
@@ -55,7 +57,10 @@ def create_app() -> Litestar:
5557
# Lifecycle
5658
before_send=[log.controller.BeforeSendHandler()],
5759
on_shutdown=[],
58-
on_startup=[lambda: log.configure(log.default_processors)], # type: ignore[arg-type]
60+
on_startup=[
61+
lambda: log.configure(log.default_processors), # type: ignore[arg-type]
62+
set_startup_time,
63+
],
5964
on_app_init=[],
6065
# Other
6166
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
@@ -28,6 +28,7 @@
2828
system.controllers.system.SystemController,
2929
system.controllers.health.HealthController,
3030
web.controllers.web.WebController,
31+
web.controllers.websocket.dashboard_stream,
3132
guilds.controllers.GuildsController,
3233
]
3334
"""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: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""WebSocket controller for real-time dashboard updates."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from datetime import UTC, datetime
7+
from typing import TYPE_CHECKING
8+
9+
import structlog
10+
from litestar import WebSocket, websocket
11+
from litestar.exceptions import WebSocketDisconnect
12+
from sqlalchemy import func, select
13+
14+
from byte_common.models.guild import Guild
15+
16+
if TYPE_CHECKING:
17+
from sqlalchemy.ext.asyncio import AsyncSession
18+
19+
20+
logger = structlog.get_logger()
21+
22+
__all__ = ("dashboard_stream",)
23+
24+
# Track application startup time for uptime calculation
25+
_startup_time: datetime | None = None
26+
27+
28+
def set_startup_time() -> None:
29+
"""Set the application startup time (call from app.on_startup)."""
30+
global _startup_time # noqa: PLW0603
31+
_startup_time = datetime.now(UTC)
32+
logger.info("Application startup time recorded", startup_time=_startup_time.isoformat())
33+
34+
35+
def get_uptime_seconds() -> int:
36+
"""Get application uptime in seconds.
37+
38+
Returns:
39+
int: Uptime in seconds since application start. Returns 0 if startup time not set.
40+
"""
41+
if _startup_time is None:
42+
return 0
43+
delta = datetime.now(UTC) - _startup_time
44+
return int(delta.total_seconds())
45+
46+
47+
async def get_server_count(db_session: AsyncSession) -> int:
48+
"""Get current server/guild count from database.
49+
50+
Args:
51+
db_session: Database session for querying guilds.
52+
53+
Returns:
54+
int: Number of guilds in the database.
55+
"""
56+
result = await db_session.execute(select(func.count()).select_from(Guild))
57+
return result.scalar_one()
58+
59+
60+
@websocket("/ws/dashboard")
61+
async def dashboard_stream(socket: WebSocket, db_session: AsyncSession) -> None:
62+
"""Stream real-time dashboard updates via WebSocket.
63+
64+
Sends JSON updates every 5 seconds containing:
65+
- server_count: Number of guilds
66+
- bot_status: online/offline
67+
- uptime: Seconds since startup
68+
- timestamp: ISO format timestamp
69+
70+
Args:
71+
socket: WebSocket connection.
72+
db_session: Database session injected by Litestar.
73+
"""
74+
await socket.accept()
75+
logger.info("Dashboard WebSocket client connected", client=socket.client)
76+
77+
try:
78+
while True:
79+
# Fetch current stats
80+
server_count = await get_server_count(db_session)
81+
uptime = get_uptime_seconds()
82+
83+
data = {
84+
"server_count": server_count,
85+
"bot_status": "online", # TODO: Implement actual bot status check
86+
"uptime": uptime,
87+
"timestamp": datetime.now(UTC).isoformat(),
88+
}
89+
90+
await socket.send_json(data)
91+
logger.debug("Sent dashboard update", data=data)
92+
93+
await asyncio.sleep(5)
94+
95+
except WebSocketDisconnect:
96+
logger.info("Dashboard WebSocket client disconnected", client=socket.client)
97+
except Exception:
98+
logger.exception("WebSocket error occurred")
99+
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)