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

+ + +
-
- -
+ +
+
+ +

Real-Time CDC Messages

@@ -117,72 +134,233 @@

Real-Time CDC Messages

-
+
+ + + +