diff --git a/app/internal/connection_manager.py b/app/internal/connection_manager.py
index 4235201..0676331 100644
--- a/app/internal/connection_manager.py
+++ b/app/internal/connection_manager.py
@@ -1,3 +1,7 @@
+import time
+from collections import deque
+
+from aiokafka.admin import AIOKafkaAdminClient
from fastapi import WebSocket
@@ -16,6 +20,19 @@ def __init__(self):
self.active_connections: list[WebSocket] = []
self.nicknames: dict[WebSocket, str] = {} # Store websocket -> nickname mapping
+ # Metrics tracking
+ self.message_timestamps = deque(maxlen=1000) # Track last 1000 message timestamps
+ self.cdc_events = {'create': 0, 'update': 0, 'delete': 0, 'snapshot': 0}
+ self.cdc_events_24h = deque(maxlen=10000) # Store events with timestamps for 24h tracking
+ self.total_messages = 0
+ self.start_time = time.time()
+ self.kafka_topics_count = 0 # Track Kafka topics count
+ self.kafka_bootstrap_servers = 'kafka-debezium:9092' # Default Kafka server
+
+ # Throughput tracking
+ self.cdc_event_timestamps = deque(maxlen=1000) # CDC events with timestamps for events/sec
+ self.bytes_transferred = deque(maxlen=1000) # Bytes with timestamps for bytes/sec tracking
+
async def connect(self, websocket: WebSocket, client_id: str, nickname: str = None):
await websocket.accept()
self.active_connections.append(websocket)
@@ -38,6 +55,33 @@ async def disconnect(self, websocket: WebSocket, client_id: str):
await self.broadcast(f'👋 {nickname} left the chat')
async def broadcast(self, message: str):
+ # Track message for metrics
+ current_time = time.time()
+ self.message_timestamps.append(current_time)
+ self.total_messages += 1
+
+ # Track bytes transferred (message size in bytes)
+ message_bytes = len(message.encode('utf-8'))
+ self.bytes_transferred.append({'bytes': message_bytes, 'timestamp': current_time})
+
+ # Track CDC events
+ if '[Created]' in message or 'Created' in message:
+ self.cdc_events['create'] += 1
+ self.cdc_events_24h.append({'type': 'create', 'timestamp': current_time})
+ self.cdc_event_timestamps.append(current_time)
+ elif '[Updated]' in message or 'Updated' in message:
+ self.cdc_events['update'] += 1
+ self.cdc_events_24h.append({'type': 'update', 'timestamp': current_time})
+ self.cdc_event_timestamps.append(current_time)
+ elif '[Deleted]' in message or 'Deleted' in message:
+ self.cdc_events['delete'] += 1
+ self.cdc_events_24h.append({'type': 'delete', 'timestamp': current_time})
+ self.cdc_event_timestamps.append(current_time)
+ elif '[Snapshot]' in message or 'Snapshot' in message:
+ self.cdc_events['snapshot'] += 1
+ self.cdc_events_24h.append({'type': 'snapshot', 'timestamp': current_time})
+ self.cdc_event_timestamps.append(current_time)
+
for connection in self.active_connections:
await connection.send_text(message)
@@ -48,3 +92,65 @@ def is_nickname_taken(self, nickname: str) -> bool:
def get_nickname(self, websocket: WebSocket) -> str:
"""Get nickname for a websocket connection"""
return self.nicknames.get(websocket, 'Anonymous')
+
+ async def fetch_kafka_topics_count(self) -> int:
+ """Fetch the current count of Kafka topics"""
+ try:
+ admin_client = AIOKafkaAdminClient(bootstrap_servers=self.kafka_bootstrap_servers)
+ await admin_client.start()
+ try:
+ # Get list of all topics
+ topics = await admin_client.list_topics()
+ # Filter out internal topics (those starting with __)
+ user_topics = [topic for topic in topics if not topic.startswith('__')]
+ self.kafka_topics_count = len(user_topics)
+ return self.kafka_topics_count
+ finally:
+ await admin_client.close()
+ except Exception as e:
+ print(f'Error fetching Kafka topics: {e}')
+ # Return cached value or 0 if never fetched
+ return self.kafka_topics_count
+
+ def get_metrics(self) -> dict:
+ """Get current system metrics"""
+ current_time = time.time()
+
+ # Calculate messages per minute (last 60 seconds)
+ one_minute_ago = current_time - 60
+ recent_messages = sum(1 for ts in self.message_timestamps if ts >= one_minute_ago)
+
+ # Calculate CDC events per second (last 60 seconds)
+ cdc_events_last_minute = sum(1 for ts in self.cdc_event_timestamps if ts >= one_minute_ago)
+ cdc_events_per_sec = round(cdc_events_last_minute / 60, 2) if cdc_events_last_minute > 0 else 0
+
+ # Calculate bytes per second (last 60 seconds)
+ bytes_last_minute = sum(item['bytes'] for item in self.bytes_transferred if item['timestamp'] >= one_minute_ago)
+ bytes_per_sec = round(bytes_last_minute / 60, 2) if bytes_last_minute > 0 else 0
+
+ # Calculate events in last 24 hours by type
+ twenty_four_hours_ago = current_time - (24 * 60 * 60)
+ events_24h = [e for e in self.cdc_events_24h if e['timestamp'] >= twenty_four_hours_ago]
+
+ events_24h_by_type = {
+ 'create': sum(1 for e in events_24h if e['type'] == 'create'),
+ 'update': sum(1 for e in events_24h if e['type'] == 'update'),
+ 'delete': sum(1 for e in events_24h if e['type'] == 'delete'),
+ 'snapshot': sum(1 for e in events_24h if e['type'] == 'snapshot'),
+ }
+
+ # Calculate uptime
+ uptime_seconds = int(current_time - self.start_time)
+
+ return {
+ 'connected_users': len(self.active_connections),
+ 'messages_per_minute': recent_messages,
+ 'total_messages': self.total_messages,
+ 'cdc_events': self.cdc_events,
+ 'events_24h': events_24h_by_type,
+ 'uptime_seconds': uptime_seconds,
+ 'active_nicknames': list(self.nicknames.values()),
+ 'kafka_topics': self.kafka_topics_count,
+ 'cdc_events_per_sec': cdc_events_per_sec,
+ 'bytes_per_sec': bytes_per_sec,
+ }
diff --git a/app/routes/websockets.py b/app/routes/websockets.py
index ede5574..e178991 100644
--- a/app/routes/websockets.py
+++ b/app/routes/websockets.py
@@ -1,6 +1,7 @@
import re
from fastapi import APIRouter, Query, WebSocket
+from fastapi.responses import JSONResponse
from starlette.websockets import WebSocketDisconnect
from app.internal.connection_manager import ConnectionManager
@@ -57,3 +58,12 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str, nickname: str
await manager.broadcast(message)
except WebSocketDisconnect:
await manager.disconnect(websocket, client_id)
+
+
+@router.get('/metrics')
+async def get_metrics():
+ """Get real-time system metrics"""
+ # Fetch Kafka topics count (async call)
+ await manager.fetch_kafka_topics_count()
+ metrics = manager.get_metrics()
+ return JSONResponse(content=metrics)
diff --git a/app/static/script.js b/app/static/script.js
index a0563cf..0c01435 100644
--- a/app/static/script.js
+++ b/app/static/script.js
@@ -4,12 +4,15 @@ let ws;
let messageCount = 0;
let isConnected = false;
let currentNickname = null;
+let cdcActivityChart = null;
+let metricsUpdateInterval = null;
// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
setupEventListeners();
showNicknameModal();
updateConnectionStatus('connecting', 'Waiting for nickname...');
+ initializeDashboard();
});
// Nickname Modal Functions
@@ -205,37 +208,27 @@ function handleIncomingMessage(messageData) {
}
// Create message element
- function createMessageElement(messageData) {
+function createMessageElement(messageData) {
const messageDiv = document.createElement("div");
- messageDiv.className = "message"; // unified style with fade-in animation
+ messageDiv.className = "message-item";
// Determine message type and label
- let type = 'cdc';
- if (messageData.includes("Created")) type = 'insert';
- else if (messageData.includes("Updated")) type = 'update';
- else if (messageData.includes("Deleted")) type = 'delete';
-
- messageDiv.classList.add(type);
-
- // Message content
- const contentSpan = document.createElement("span");
- contentSpan.textContent = messageData;
-
- // Timestamp
- const timeDiv = document.createElement("div");
- timeDiv.className = "time";
- timeDiv.textContent = new Date().toLocaleTimeString([], {
- hour: '2-digit',
- minute: '2-digit'
- });
+ let messageType = 'cdc';
+ let typeLabel = 'CDC EVENT';
+
+ if (messageData.includes("Created") || messageData.includes("INSERT")) {
+ messageType = 'create';
+ typeLabel = 'CREATE';
+ } else if (messageData.includes("Updated") || messageData.includes("UPDATE")) {
+ messageType = 'update';
+ typeLabel = 'UPDATE';
+ } else if (messageData.includes("Deleted") || messageData.includes("DELETE")) {
+ messageType = 'delete';
+ typeLabel = 'DELETE';
+ }
- // Combine
- messageDiv.appendChild(contentSpan);
- messageDiv.appendChild(timeDiv);
+ messageDiv.classList.add(messageType);
- return messageDiv;
-}
-
// Create message content
const contentDiv = document.createElement("div");
contentDiv.className = "message-content";
@@ -376,3 +369,205 @@ window.deleteSampleData = deleteSampleData;
window.sendMessage = sendMessage;
window.submitNickname = submitNickname;
window.editNickname = editNickname;
+window.switchTab = switchTab;
+
+// ========================================
+// DASHBOARD FUNCTIONALITY
+// ========================================
+
+function switchTab(tabName) {
+ // Update tab buttons
+ const tabs = document.querySelectorAll('.nav-tab');
+ tabs.forEach(tab => {
+ if (tab.textContent.toLowerCase().includes(tabName)) {
+ tab.classList.add('active');
+ } else {
+ tab.classList.remove('active');
+ }
+ });
+
+ // Update tab content
+ const messagesTab = document.getElementById('messages-tab');
+ const dashboardTab = document.getElementById('dashboard-tab');
+
+ if (tabName === 'messages') {
+ messagesTab.classList.add('active');
+ dashboardTab.classList.remove('active');
+
+ // Stop metrics updates when leaving dashboard
+ if (metricsUpdateInterval) {
+ clearInterval(metricsUpdateInterval);
+ metricsUpdateInterval = null;
+ }
+ } else if (tabName === 'dashboard') {
+ messagesTab.classList.remove('active');
+ dashboardTab.classList.add('active');
+
+ // Start metrics updates when entering dashboard
+ updateMetrics();
+ metricsUpdateInterval = setInterval(updateMetrics, 2000); // Update every 2 seconds
+ }
+}
+
+function initializeDashboard() {
+ // Initialize CDC Activity Chart
+ const ctx = document.getElementById('cdcActivityChart');
+ if (ctx) {
+ cdcActivityChart = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [
+ {
+ label: 'Create',
+ data: [],
+ borderColor: '#10b981',
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
+ tension: 0.4,
+ fill: true
+ },
+ {
+ label: 'Update',
+ data: [],
+ borderColor: '#f59e0b',
+ backgroundColor: 'rgba(245, 158, 11, 0.1)',
+ tension: 0.4,
+ fill: true
+ },
+ {
+ label: 'Delete',
+ data: [],
+ borderColor: '#ef4444',
+ backgroundColor: 'rgba(239, 68, 68, 0.1)',
+ tension: 0.4,
+ fill: true
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom',
+ }
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ ticks: {
+ stepSize: 1
+ }
+ }
+ }
+ }
+ });
+ }
+}
+
+async function updateMetrics() {
+ try {
+ const response = await fetch('/ws/metrics');
+ const metrics = await response.json();
+
+ // Update metric cards
+ document.getElementById('metric-users').textContent = metrics.connected_users;
+ document.getElementById('metric-msg-min').textContent = metrics.messages_per_minute;
+ document.getElementById('metric-topics').textContent = metrics.kafka_topics || 0;
+
+ // Update throughput metrics
+ document.getElementById('metric-cdc-rate').textContent = metrics.cdc_events_per_sec || 0;
+
+ // Format bytes per second with units
+ const bytesPerSec = metrics.bytes_per_sec || 0;
+ let formattedBytes;
+ if (bytesPerSec < 1024) {
+ formattedBytes = bytesPerSec.toFixed(0) + ' B/s';
+ } else if (bytesPerSec < 1024 * 1024) {
+ formattedBytes = (bytesPerSec / 1024).toFixed(2) + ' KB/s';
+ } else {
+ formattedBytes = (bytesPerSec / (1024 * 1024)).toFixed(2) + ' MB/s';
+ }
+ document.getElementById('metric-bytes-rate').textContent = formattedBytes;
+
+ // Update operation distribution with percentage-based fill
+ const total24h = metrics.events_24h.create + metrics.events_24h.update + metrics.events_24h.delete;
+
+ if (total24h > 0) {
+ // Calculate percentage of total operations
+ const createPercent = (metrics.events_24h.create / total24h) * 100;
+ const updatePercent = (metrics.events_24h.update / total24h) * 100;
+ const deletePercent = (metrics.events_24h.delete / total24h) * 100;
+
+ document.getElementById('op-create-bar').style.width = createPercent + '%';
+ document.getElementById('op-update-bar').style.width = updatePercent + '%';
+ document.getElementById('op-delete-bar').style.width = deletePercent + '%';
+ } else {
+ document.getElementById('op-create-bar').style.width = '0%';
+ document.getElementById('op-update-bar').style.width = '0%';
+ document.getElementById('op-delete-bar').style.width = '0%';
+ }
+
+ document.getElementById('op-create-count').textContent = metrics.events_24h.create;
+ document.getElementById('op-update-count').textContent = metrics.events_24h.update;
+ document.getElementById('op-delete-count').textContent = metrics.events_24h.delete;
+
+ // Update stats
+ document.getElementById('stat-total-messages').textContent = metrics.total_messages;
+ document.getElementById('stat-uptime').textContent = formatUptime(metrics.uptime_seconds);
+
+ // Show count instead of names to avoid overflow
+ const activeUsersCount = metrics.active_nicknames.length;
+ const activeUsersText = activeUsersCount > 0
+ ? `${activeUsersCount} user${activeUsersCount !== 1 ? 's' : ''} online`
+ : 'No users';
+ document.getElementById('stat-active-users').textContent = activeUsersText;
+ document.getElementById('stat-active-users').title = metrics.active_nicknames.join(', ') || 'None';
+
+ document.getElementById('stat-create').textContent = metrics.cdc_events.create;
+ document.getElementById('stat-update').textContent = metrics.cdc_events.update;
+ document.getElementById('stat-delete').textContent = metrics.cdc_events.delete;
+
+ // Update CDC Activity Chart
+ updateCDCChart(metrics.cdc_events);
+
+ } catch (error) {
+ console.error('Failed to fetch metrics:', error);
+ }
+}
+
+function updateCDCChart(cdcEvents) {
+ if (!cdcActivityChart) return;
+
+ const now = new Date().toLocaleTimeString();
+
+ // Keep last 20 data points
+ if (cdcActivityChart.data.labels.length > 20) {
+ cdcActivityChart.data.labels.shift();
+ cdcActivityChart.data.datasets.forEach(dataset => {
+ dataset.data.shift();
+ });
+ }
+
+ cdcActivityChart.data.labels.push(now);
+ cdcActivityChart.data.datasets[0].data.push(cdcEvents.create);
+ cdcActivityChart.data.datasets[1].data.push(cdcEvents.update);
+ cdcActivityChart.data.datasets[2].data.push(cdcEvents.delete);
+
+ cdcActivityChart.update('none'); // Update without animation for smoother real-time updates
+}
+
+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`;
+ } else if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ } else {
+ return `${minutes}m`;
+ }
+}
+
diff --git a/app/static/styles.css b/app/static/styles.css
index eb6cca6..84e201a 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -36,17 +36,14 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
- max-width: 1400px;
- margin: 0 auto;
+ width: 100%;
background: var(--surface);
- box-shadow: var(--shadow-lg);
}
/* Header */
.header {
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
color: white;
- padding: 1rem 2rem;
box-shadow: var(--shadow);
}
@@ -54,8 +51,8 @@ body {
display: flex;
justify-content: space-between;
align-items: center;
- max-width: 1200px;
- margin: 0 auto;
+ width: 100%;
+ padding: 1rem 2rem;
}
.logo {
@@ -115,6 +112,73 @@ body {
opacity: 0.9;
}
+/* Navigation Tabs */
+.nav-tabs {
+ display: flex;
+ gap: 0.5rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(0, 0, 0, 0.1);
+}
+
+.nav-tabs-container {
+ width: 100%;
+ padding: 0 2rem;
+ display: flex;
+ gap: 0.5rem;
+}
+
+.nav-tab {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.875rem 1.5rem;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.7);
+ border: none;
+ border-bottom: 3px solid transparent;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all 0.3s ease;
+ position: relative;
+ white-space: nowrap;
+}
+
+.nav-tab:hover {
+ background: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.nav-tab.active {
+ color: white;
+ border-bottom-color: white;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.nav-tab i {
+ font-size: 1rem;
+}
+
+/* Tab Content */
+.tab-content {
+ display: none;
+ flex: 1;
+}
+
+.tab-content.active {
+ display: flex;
+}
+
+#messages-tab.active {
+ display: flex;
+ flex-direction: row;
+}
+
+#dashboard-tab.active {
+ display: flex;
+ flex-direction: column;
+}
+
/* Main Chat Area */
.chat-main {
display: flex;
@@ -281,8 +345,7 @@ body {
}
.message-form {
- max-width: 800px;
- margin: 0 auto;
+ width: 100%;
}
.input-group {
@@ -465,13 +528,35 @@ body {
display: flex;
justify-content: space-between;
align-items: center;
- max-width: 1200px;
- margin: 0 auto;
+ width: 100%;
+ padding: 0 2rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Responsive Design */
+/* Large tablets and small desktops (1024px and below) */
+@media (max-width: 1024px) {
+ .header-content,
+ .nav-tabs-container,
+ .footer-content {
+ padding: 1rem 1.5rem;
+ }
+
+ .charts-container {
+ grid-template-columns: 1fr;
+ }
+
+ .metrics-grid {
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ }
+
+ .dashboard-container {
+ padding: 1.5rem;
+ }
+}
+
+/* Tablets (768px and below) */
@media (max-width: 768px) {
.app-container {
margin: 0;
@@ -482,15 +567,70 @@ body {
flex-direction: column;
gap: 1rem;
text-align: center;
+ padding: 1rem;
+ }
+
+ .logo h1 {
+ font-size: 1.25rem;
+ }
+
+ .nav-tabs-container {
+ padding: 0 1rem;
+ }
+
+ .nav-tab {
+ flex: 1;
+ justify-content: center;
+ padding: 0.75rem 1rem;
+ font-size: 0.8125rem;
+ }
+
+ .nav-tab span {
+ display: none;
+ }
+
+ .nav-tab i {
+ font-size: 1.25rem;
}
.user-info {
flex-direction: column;
- gap: 1rem;
+ gap: 0.75rem;
+ font-size: 0.8125rem;
+ }
+
+ #messages-tab.active {
+ flex-direction: column;
+ }
+
+ .messages-header {
+ padding: 0.875rem 1rem;
}
- .chat-main {
+ .messages-header h3 {
+ font-size: 1rem;
+ }
+
+ .input-container {
+ padding: 1rem;
+ }
+
+ .message-form {
+ max-width: 100%;
+ }
+
+ .input-footer {
flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .help-text {
+ font-size: 0.6875rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
}
.sidebar {
@@ -523,6 +663,237 @@ body {
flex-direction: column;
gap: 0.5rem;
text-align: center;
+ padding: 0.875rem 1rem;
+ font-size: 0.8125rem;
+ }
+
+ /* Dashboard responsive */
+ .dashboard-container {
+ padding: 1rem;
+ }
+
+ .charts-container {
+ grid-template-columns: 1fr;
+ }
+
+ .metrics-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 1rem;
+ }
+
+ .metric-card {
+ padding: 1rem;
+ flex-direction: column;
+ text-align: center;
+ }
+
+ .metric-value {
+ font-size: 1.5rem;
+ }
+
+ .chart-header {
+ padding: 1rem;
+ }
+
+ .chart-body {
+ padding: 1rem;
+ }
+
+ .system-info-item {
+ padding: 0.875rem 1rem;
+ }
+}
+
+/* Mobile devices (480px and below) */
+@media (max-width: 480px) {
+ .logo h1 {
+ font-size: 1rem;
+ }
+
+ .logo i {
+ font-size: 1.25rem;
+ }
+
+ .header-content,
+ .nav-tabs-container {
+ padding: 0.875rem;
+ }
+
+ .nav-tab {
+ padding: 0.625rem 0.75rem;
+ }
+
+ .user-info {
+ font-size: 0.75rem;
+ }
+
+ .messages-header h3 {
+ font-size: 0.875rem;
+ }
+
+ .message-count {
+ font-size: 0.75rem;
+ }
+
+ .input-group input {
+ font-size: 0.875rem;
+ padding: 0.625rem 0.875rem;
+ }
+
+ .send-button {
+ padding: 0.625rem 1rem;
+ font-size: 0.8125rem;
+ }
+
+ .send-button span {
+ display: none;
+ }
+
+ .help-text {
+ display: none;
+ }
+
+ .sidebar-content {
+ padding: 0.875rem;
+ }
+
+ .section {
+ margin-bottom: 1.5rem;
+ }
+
+ .section h4 {
+ font-size: 0.875rem;
+ }
+
+ .section p {
+ font-size: 0.8125rem;
+ }
+
+ .command-btn,
+ .links a {
+ font-size: 0.8125rem;
+ padding: 0.625rem;
+ }
+
+ /* Dashboard mobile */
+ .dashboard-container {
+ padding: 0.875rem;
+ }
+
+ .metrics-grid {
+ grid-template-columns: 1fr;
+ gap: 0.875rem;
+ }
+
+ .metric-card {
+ padding: 0.875rem;
+ }
+
+ .metric-icon {
+ width: 50px;
+ height: 50px;
+ font-size: 1.25rem;
+ }
+
+ .metric-label {
+ font-size: 0.8125rem;
+ }
+
+ .metric-value {
+ font-size: 1.25rem;
+ }
+
+ .chart-header h3 {
+ font-size: 0.875rem;
+ }
+
+ .chart-header,
+ .chart-body {
+ padding: 0.875rem;
+ }
+
+ .operation-label {
+ font-size: 0.6875rem;
+ }
+
+ .operation-count {
+ font-size: 1rem;
+ }
+
+ .operation-bar-track {
+ height: 24px;
+ }
+
+ .system-info-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .system-info-item {
+ padding: 0.75rem 0.875rem;
+ }
+
+ .info-label {
+ font-size: 0.75rem;
+ }
+
+ .info-value {
+ font-size: 0.875rem;
+ }
+
+ .footer-content {
+ font-size: 0.75rem;
+ padding: 0.75rem 0.875rem;
+ }
+
+ /* Modal responsive */
+ .modal-container {
+ width: 95%;
+ }
+
+ .modal-header {
+ padding: 1rem;
+ }
+
+ .modal-header h2 {
+ font-size: 1.125rem;
+ }
+
+ .modal-header i {
+ font-size: 1.5rem;
+ }
+
+ .modal-body {
+ padding: 1.5rem 1rem;
+ }
+
+ .modal-description {
+ font-size: 0.8125rem;
+ }
+
+ .btn-primary {
+ padding: 0.75rem 1.5rem;
+ font-size: 0.875rem;
+ }
+}
+
+/* Very small mobile devices (360px and below) */
+@media (max-width: 360px) {
+ .logo h1 {
+ font-size: 0.875rem;
+ }
+
+ .header-content,
+ .nav-tabs-container,
+ .footer-content {
+ padding: 0.75rem;
+ }
+
+ .metric-value {
+ font-size: 1.125rem;
+ }
+
+ .chart-header h3 {
+ font-size: 0.8125rem;
}
}
@@ -583,7 +954,7 @@ body {
opacity: 1;
transform: translateX(0);
}
-
+}
/* Nickname Modal Styles */
.modal-overlay {
@@ -776,4 +1147,301 @@ body {
#nickname-display {
font-weight: 600;
-}
+}
+
+/* ===============================================
+ DASHBOARD STYLES
+ =============================================== */
+
+.dashboard-container {
+ padding: 2rem;
+ background: var(--background);
+ overflow-y: auto;
+ flex: 1;
+}
+
+/* Metrics Grid */
+.metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.metric-card {
+ background: var(--surface);
+ border-radius: var(--radius-lg);
+ padding: 1.5rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.metric-card:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-lg);
+}
+
+.metric-icon {
+ width: 60px;
+ height: 60px;
+ border-radius: var(--radius);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.5rem;
+ background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
+ color: white;
+}
+
+.metric-content {
+ flex: 1;
+}
+
+.metric-label {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.25rem;
+}
+
+.metric-value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+/* Charts Container */
+.charts-container {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.chart-card {
+ background: var(--surface);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+}
+
+.chart-header {
+ padding: 1.25rem 1.5rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--background);
+}
+
+.chart-header h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0;
+}
+
+.chart-body {
+ padding: 1.5rem;
+}
+
+#cdcActivityChart {
+ max-height: 300px;
+}
+
+/* Operation Bars */
+.operation-bars {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.operation-bar-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.operation-bar-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.operation-label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ padding: 0.25rem 0.75rem;
+ border-radius: var(--radius);
+ text-transform: uppercase;
+}
+
+.operation-label.create {
+ background: #dcfce7;
+ color: #166534;
+}
+
+.operation-label.update {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.operation-label.delete {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.operation-count {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.operation-bar-track {
+ height: 32px;
+ background: var(--background);
+ border-radius: var(--radius);
+ overflow: hidden;
+ position: relative;
+}
+
+.operation-bar-fill {
+ height: 100%;
+ transition: width 0.5s ease;
+ display: flex;
+ align-items: center;
+ padding: 0 0.75rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: white;
+}
+
+.operation-bar-fill.create {
+ background: linear-gradient(90deg, #10b981, #059669);
+}
+
+.operation-bar-fill.update {
+ background: linear-gradient(90deg, #f59e0b, #d97706);
+}
+
+.operation-bar-fill.delete {
+ background: linear-gradient(90deg, #ef4444, #dc2626);
+}
+
+/* Stats Grid */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
+ gap: 1.5rem;
+}
+
+.stat-card {
+ background: var(--surface);
+ border-radius: var(--radius-lg);
+ padding: 1.5rem;
+ box-shadow: var(--shadow);
+}
+
+.stat-card h4 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.stat-items {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.stat-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.stat-item:last-child {
+ border-bottom: none;
+}
+
+.stat-label {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+.stat-value {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.stat-value.stat-create {
+ color: var(--success-color);
+}
+
+.stat-value.stat-update {
+ color: var(--warning-color);
+}
+
+.stat-value.stat-delete {
+ color: var(--danger-color);
+}
+
+/* ===============================================
+ UTILITY CLASSES FOR RESPONSIVE DESIGN
+ =============================================== */
+
+/* Prevent text overflow */
+.text-truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Word wrap for long text */
+.text-wrap {
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ hyphens: auto;
+}
+
+/* Scrollable containers */
+.scrollable-x {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.scrollable-y {
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+/* Ensure images are responsive */
+img {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Smooth scrolling */
+html {
+ scroll-behavior: smooth;
+}
+
+/* Prevent horizontal overflow on body */
+body {
+ overflow-x: hidden;
+}
+
+/* Ensure all containers respect viewport */
+* {
+ min-width: 0;
+ min-height: 0;
+}
diff --git a/app/templates/index.html b/app/templates/index.html
index f5c39d9..a373b8a 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -6,6 +6,7 @@
🚀 Debezium Real-Time Chat - CDC Learning Project
+
@@ -67,13 +68,29 @@ Debezium Real-Time Chat
+
+
+
-