Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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",
]
99 changes: 99 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,99 @@
"""WebSocket controller for real-time dashboard updates."""

from __future__ import annotations

import asyncio
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


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:
while True:
# Fetch current stats
server_count = await get_server_count(db_session)
uptime = get_uptime_seconds()

data = {
"server_count": server_count,
"bot_status": "online", # TODO: Implement actual bot status check
"uptime": uptime,
"timestamp": datetime.now(UTC).isoformat(),
}

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

await asyncio.sleep(5)

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