Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion services/api/src/byte_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ def create_app() -> Litestar:

dependencies = create_collection_dependencies()

from byte_api.domain.web.controllers.websocket import set_startup_time

return Litestar(
# Handlers
exception_handlers={
Expand All @@ -55,7 +57,10 @@ def create_app() -> Litestar:
# Lifecycle
before_send=[log.controller.BeforeSendHandler()],
on_shutdown=[],
on_startup=[lambda: log.configure(log.default_processors)], # type: ignore[arg-type]
on_startup=[
lambda: log.configure(log.default_processors), # type: ignore[arg-type]
set_startup_time,
],
on_app_init=[],
# Other
debug=settings.project.DEBUG,
Expand Down
1 change: 1 addition & 0 deletions services/api/src/byte_api/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
system.controllers.system.SystemController,
system.controllers.health.HealthController,
web.controllers.web.WebController,
web.controllers.websocket.dashboard_stream,
guilds.controllers.GuildsController,
]
"""Routes for the application."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

from __future__ import annotations

from byte_api.domain.web.controllers import web
from byte_api.domain.web.controllers import web, websocket

__all__ = [
"web",
"websocket",
]
114 changes: 114 additions & 0 deletions services/api/src/byte_api/domain/web/controllers/websocket.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""WebSocket controller for real-time dashboard updates."""

from __future__ import annotations

import asyncio
import os
from datetime import UTC, datetime
from typing import TYPE_CHECKING

import structlog
from litestar import WebSocket, websocket
from litestar.exceptions import WebSocketDisconnect
from sqlalchemy import func, select

from byte_common.models.guild import Guild

if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession


logger = structlog.get_logger()

__all__ = ("dashboard_stream",)

# Track application startup time for uptime calculation
_startup_time: datetime | None = None

# WebSocket update interval (configurable for testing)
UPDATE_INTERVAL = float(os.getenv("WS_UPDATE_INTERVAL", "5.0"))


def set_startup_time() -> None:
"""Set the application startup time (call from app.on_startup)."""
global _startup_time # noqa: PLW0603
_startup_time = datetime.now(UTC)
logger.info("Application startup time recorded", startup_time=_startup_time.isoformat())


def get_uptime_seconds() -> int:
"""Get application uptime in seconds.

Returns:
int: Uptime in seconds since application start. Returns 0 if startup time not set.
"""
if _startup_time is None:
return 0
delta = datetime.now(UTC) - _startup_time
return int(delta.total_seconds())


async def get_server_count(db_session: AsyncSession) -> int:
"""Get current server/guild count from database.

Args:
db_session: Database session for querying guilds.

Returns:
int: Number of guilds in the database.
"""
result = await db_session.execute(select(func.count()).select_from(Guild))
return result.scalar_one()


@websocket("/ws/dashboard")
async def dashboard_stream(socket: WebSocket, db_session: AsyncSession) -> None:
"""Stream real-time dashboard updates via WebSocket.

Sends JSON updates every 5 seconds containing:
- server_count: Number of guilds
- bot_status: online/offline
- uptime: Seconds since startup
- timestamp: ISO format timestamp

Args:
socket: WebSocket connection.
db_session: Database session injected by Litestar.
"""
await socket.accept()
logger.info("Dashboard WebSocket client connected", client=socket.client)

try:
# Send updates in a loop
while True:
try:
server_count = await get_server_count(db_session)
uptime = get_uptime_seconds()

data = {
"server_count": server_count,
"bot_status": "online",
"uptime": uptime,
"timestamp": datetime.now(UTC).isoformat(),
}

await socket.send_json(data)
logger.debug("Sent dashboard update", data=data)

# Sleep - any send failures will be caught and exit loop
await asyncio.sleep(UPDATE_INTERVAL)

except (WebSocketDisconnect, RuntimeError):
# Client disconnected or connection closed
logger.info("WebSocket client disconnected")
break

except asyncio.CancelledError:
# Task was cancelled (e.g., test cleanup)
logger.info("WebSocket handler cancelled")
raise
except WebSocketDisconnect:
logger.info("Dashboard WebSocket client disconnected", client=socket.client)
except Exception:
logger.exception("WebSocket error occurred")
raise
25 changes: 25 additions & 0 deletions services/api/src/byte_api/domain/web/resources/input.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

/* WebSocket status indicator styles */
.ws-status {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}

.ws-status-connected {
background-color: #10b981;
color: white;
}

.ws-status-disconnected {
background-color: #f59e0b;
color: white;
}

.ws-status-error,
.ws-status-failed {
background-color: #ef4444;
color: white;
}
138 changes: 131 additions & 7 deletions services/api/src/byte_api/domain/web/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ <h1 class="text-3xl font-bold tracking-tight text-white">Dashboard</h1>
</svg>
</div>
<div class="stat-title text-base-content">Server Count</div>
<div class="stat-value text-primary">1</div>
<div class="stat-desc">100% more than before Byte was born</div>
<div class="stat-value text-primary" id="server-count">-</div>
<div class="stat-desc">Servers connected to Byte</div>
</div>

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

<div class="stat">
<div class="stat-figure text-secondary">
<div class="avatar online">
<div class="avatar" id="bot-avatar">
<div class="w-16 rounded-full">
<img src="static/logo.svg" alt="Byte Logo" />
</div>
</div>
</div>
<div class="stat-title text-base-content">Uptime</div>
<div class="stat-value text-primary">99.99%</div>
<div class="stat-desc text-warning">2 issues in the last 24 hours</div>
<div class="stat-title text-base-content">Bot Status</div>
<div class="stat-value">
<span id="bot-status" class="badge badge-lg">-</span>
</div>
<div class="stat-desc">Uptime: <span id="uptime">-</span></div>
</div>
</div>
<div class="mt-4 text-sm text-center text-base-content/60 dark:text-base-100/40">
Last update: <span id="last-update">-</span>
| WebSocket: <span id="ws-status" class="ws-status">disconnected</span>
</div>
<div class="mt-10 p-4 rounded-lg shadow-2xl bg-base-100/60 dark:bg-neutral/60">
<h1 class="text-5xl font-bold text-base-content dark:text-base-100/80">Activity</h1>
<div class="h-[calc(50vh-10em)] overflow-y-auto">
Expand Down Expand Up @@ -487,4 +493,122 @@ <h1 class="text-5xl font-bold text-base-content dark:text-base-100/80">Activity<
</div>
</div>
</main>
{% endblock content %} {% block extrajs %}{% endblock extrajs %}
{% endblock content %}
{% block extrajs %}
<script>
let ws = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;

function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;

console.log('Connecting to WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);

ws.onopen = () => {
console.log('WebSocket connected');
reconnectAttempts = 0;
updateConnectionStatus('connected');
};

ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('WebSocket message received:', data);
updateDashboard(data);
};

ws.onclose = () => {
console.log('WebSocket closed, attempting reconnect...');
updateConnectionStatus('disconnected');

if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const delay = 3000 * reconnectAttempts;
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);
setTimeout(connectWebSocket, delay);
} else {
console.error('Max reconnection attempts reached');
updateConnectionStatus('failed');
}
};

ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus('error');
};
}

function updateDashboard(data) {
// Update server count
const serverCountEl = document.getElementById('server-count');
if (serverCountEl) {
serverCountEl.textContent = data.server_count;
}

// Update bot status
const botStatusEl = document.getElementById('bot-status');
const botAvatarEl = document.getElementById('bot-avatar');
if (botStatusEl) {
const isOnline = data.bot_status === 'online';
botStatusEl.textContent = data.bot_status;
botStatusEl.className = isOnline ? 'badge badge-lg badge-success' : 'badge badge-lg badge-error';

// Update avatar online indicator
if (botAvatarEl) {
if (isOnline) {
botAvatarEl.classList.add('online');
} else {
botAvatarEl.classList.remove('online');
}
}
}

// Update uptime
const uptimeEl = document.getElementById('uptime');
if (uptimeEl) {
uptimeEl.textContent = formatUptime(data.uptime);
}

// Update last update timestamp
const timestampEl = document.getElementById('last-update');
if (timestampEl) {
const updateTime = new Date(data.timestamp);
timestampEl.textContent = updateTime.toLocaleTimeString();
}
}

function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);

if (days > 0) {
return `${days}d ${hours}h ${minutes}m`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}

function updateConnectionStatus(status) {
const statusEl = document.getElementById('ws-status');
if (statusEl) {
statusEl.textContent = status;
statusEl.className = `ws-status ws-status-${status}`;
}
}

// Connect on page load
document.addEventListener('DOMContentLoaded', connectWebSocket);

// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (ws) {
ws.close();
}
});
</script>
{% endblock extrajs %}
Loading
Loading