diff --git a/app/main.py b/app/main.py index c8b021d..7dc1741 100644 --- a/app/main.py +++ b/app/main.py @@ -193,9 +193,9 @@ async def read_root(request: Request): {"request": request, "service_uuid": SERVICE_UUID} ) -@app.get("/{service_uuid}/logs") -async def get_logs(service_uuid: str, limit: int = 100): - """Get recent logs for the service.""" +@app.get("/{service_uuid}/logs", response_class=HTMLResponse) +async def get_logs(request: Request, service_uuid: str, limit: int = 100): + """Serve the logs web page with recent logs for the service.""" if service_uuid != SERVICE_UUID: return JSONResponse( status_code=404, @@ -203,12 +203,15 @@ async def get_logs(service_uuid: str, limit: int = 100): ) logs = log_buffer.get_logs(limit) - return { - "service_uuid": service_uuid, - "log_count": len(logs), - "logs": logs - } - + return templates.TemplateResponse( + "logs.html", + { + "request": request, + "service_uuid": service_uuid, + "logs": logs, + "log_count": len(logs) + } + ) @app.put("/{service_uuid}/{node_uuid}/", status_code=status.HTTP_200_OK) async def update_node_status( @@ -284,13 +287,29 @@ async def update_node_status( @app.get("/nodes/status") async def get_all_nodes_status(): - """Returns the current status of all known nodes for the UI.""" + """Returns the current status of all known nodes for the UI, including ping latencies.""" logger.info("Fetching all nodes status for UI.") response_nodes = [] + for node_uuid, data in known_nodes_db.items(): - # Dynamically calculate health for each node based on its current data + # Dynamically calculate health for each node current_health = get_node_health(data) - + + # Build connections dictionary with raw ping latencies + connections = {} + for target_uuid in known_nodes_db: + if target_uuid != node_uuid: # Exclude self + # Fetch recent ping data (last 5 minutes to account for RRD step=60s) + ping_data = database.get_ping_data(node_uuid, target_uuid, start_time="-300s") + latency_ms = None + if ping_data and ping_data['data']['latency']: + # Get the most recent non-null latency + for latency in reversed(ping_data['data']['latency']): + if latency is not None: + latency_ms = float(latency) + break + connections[target_uuid] = latency_ms + response_nodes.append({ "uuid": node_uuid, "last_seen": data["last_seen"], @@ -298,7 +317,8 @@ async def get_all_nodes_status(): "health_status": current_health, "uptime_seconds": data.get("uptime_seconds"), "load_avg": data.get("load_avg"), - "memory_usage_percent": data.get("memory_usage_percent") + "memory_usage_percent": data.get("memory_usage_percent"), + "connections": connections }) return {"nodes": response_nodes} diff --git a/app/web/static/logs.js b/app/web/static/logs.js new file mode 100644 index 0000000..80b7c7b --- /dev/null +++ b/app/web/static/logs.js @@ -0,0 +1,126 @@ +document.addEventListener('DOMContentLoaded', () => { + const logTableContainer = document.getElementById('log-table-container'); + const logCountSpan = document.getElementById('log-count'); + const POLLING_INTERVAL_MS = 5000; // Poll every 5 seconds + const serviceUuid = logTableContainer.dataset.serviceUuid || '{{ service_uuid }}'; // Fallback for non-dynamic rendering + + async function fetchLogs() { + try { + const response = await fetch(`/${serviceUuid}/logs?limit=100`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + renderLogTable(data.logs); + logCountSpan.textContent = data.log_count; + } catch (error) { + console.error("Error fetching logs:", error); + logTableContainer.innerHTML = '

Error loading logs. Please check server connection.

'; + } + } + + function renderLogTable(logs) { + logTableContainer.innerHTML = ''; // Clear existing content + + if (logs.length === 0) { + logTableContainer.innerHTML = '

No logs available.

'; + return; + } + + // Create table + const table = document.createElement('table'); + table.classList.add('log-table'); + + // Create header + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + const headers = [ + { key: 'timestamp', label: 'Timestamp' }, + { key: 'level', label: 'Level' }, + { key: 'message', label: 'Message' }, + { key: 'extra', label: 'Extra' } + ]; + + headers.forEach(header => { + const th = document.createElement('th'); + th.textContent = header.label; + th.dataset.key = header.key; + th.classList.add('sortable'); + th.addEventListener('click', () => sortTable(header.key)); + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + // Create body + const tbody = document.createElement('tbody'); + logs.forEach(log => { + const row = document.createElement('tr'); + row.innerHTML = ` + ${new Date(log.timestamp).toLocaleString()} + ${log.level} + ${escapeHtml(log.message)} + + ${log.extra ? ` +
Show JSON
+ + ` : '-'} + + `; + tbody.appendChild(row); + }); + table.appendChild(tbody); + + logTableContainer.appendChild(table); + + // Add click handlers for JSON toggles + document.querySelectorAll('.json-toggle').forEach(toggle => { + toggle.addEventListener('click', () => { + const jsonContent = toggle.nextElementSibling; + const isHidden = jsonContent.style.display === 'none'; + jsonContent.style.display = isHidden ? 'block' : 'none'; + toggle.textContent = isHidden ? 'Hide JSON' : 'Show JSON'; + }); + }); + } + + function sortTable(key) { + const table = document.querySelector('.log-table'); + const tbody = table.querySelector('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + const isAsc = table.dataset.sortKey === key && table.dataset.sortDir !== 'desc'; + table.dataset.sortKey = key; + table.dataset.sortDir = isAsc ? 'desc' : 'asc'; + + rows.sort((a, b) => { + let aValue, bValue; + if (key === 'timestamp') { + aValue = new Date(a.cells[0].textContent); + bValue = new Date(b.cells[0].textContent); + } else if (key === 'level') { + aValue = a.cells[1].textContent.toLowerCase(); + bValue = b.cells[1].textContent.toLowerCase(); + } else if (key === 'message') { + aValue = a.cells[2].textContent.toLowerCase(); + bValue = b.cells[2].textContent.toLowerCase(); + } else { + aValue = a.cells[3].querySelector('.json-content')?.textContent || ''; + bValue = b.cells[3].querySelector('.json-content')?.textContent || ''; + } + return isAsc ? (aValue < bValue ? -1 : 1) : (aValue > bValue ? -1 : 1); + }); + + tbody.innerHTML = ''; + rows.forEach(row => tbody.appendChild(row)); + } + + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + // Initial fetch and polling + fetchLogs(); + setInterval(fetchLogs, POLLING_INTERVAL_MS); +}); \ No newline at end of file diff --git a/app/web/static/script.js b/app/web/static/script.js index 74d50ab..179a43c 100644 --- a/app/web/static/script.js +++ b/app/web/static/script.js @@ -1,6 +1,7 @@ document.addEventListener('DOMContentLoaded', () => { const nodeGridContainer = document.getElementById('node-grid-container'); const nodeCountSpan = document.getElementById('node-count'); + const serviceUuidPara = document.getElementById('service-uuid'); const POLLING_INTERVAL_MS = 3000; // Poll every 3 seconds async function fetchNodeData() { @@ -14,40 +15,106 @@ document.addEventListener('DOMContentLoaded', () => { } catch (error) { console.error("Error fetching node data:", error); nodeGridContainer.innerHTML = '

Error loading node data. Please check server connection.

'; + serviceUuidPara.style.display = 'block'; // Show UUID on error } } function renderNodeGrid(nodes) { - nodeGridContainer.innerHTML = ''; // Clear existing nodes + nodeGridContainer.innerHTML = ''; // Clear existing content nodeCountSpan.textContent = nodes.length; // Update total node count + serviceUuidPara.style.display = nodes.length === 0 ? 'block' : 'none'; // Toggle Service UUID if (nodes.length === 0) { nodeGridContainer.innerHTML = '

No nodes reporting yet. Start a client!

'; return; } + // Ensure we have exactly 4 nodes + if (nodes.length !== 4) { + nodeGridContainer.innerHTML = '

Expected exactly 4 nodes, got ' + nodes.length + '.

'; + return; + } + + // Create a 4x4 grid container + const grid = document.createElement('div'); + grid.classList.add('connection-grid'); + + // Add header row for column UUIDs + const headerRow = document.createElement('div'); + headerRow.classList.add('grid-row', 'header-row'); + headerRow.appendChild(createEmptyCell()); // Top-left corner nodes.forEach(node => { - const nodeCell = document.createElement('div'); - nodeCell.classList.add('node-cell'); - nodeCell.classList.add(`node-${node.health_status}`); // Apply health color class - - // Truncate UUID for display + const headerCell = document.createElement('div'); + headerCell.classList.add('grid-cell', 'header-cell'); const displayUuid = node.uuid.substring(0, 8) + '...'; - - nodeCell.innerHTML = ` -
${displayUuid}
-
Status: ${node.health_status.toUpperCase()}
-
-

UUID: ${node.uuid}

-

IP: ${node.ip}

-

Last Seen: ${new Date(node.last_seen).toLocaleTimeString()}

-

Uptime: ${node.uptime_seconds ? formatUptime(node.uptime_seconds) : 'N/A'}

-

Load Avg (1m, 5m, 15m): ${node.load_avg ? node.load_avg.join(', ') : 'N/A'}

-

Memory Usage: ${node.memory_usage_percent ? node.memory_usage_percent.toFixed(2) + '%' : 'N/A'}

-
- `; - nodeGridContainer.appendChild(nodeCell); + headerCell.innerHTML = `
${displayUuid}
`; + headerRow.appendChild(headerCell); }); + grid.appendChild(headerRow); + + // Create rows for the 4x4 grid + nodes.forEach((rowNode, rowIndex) => { + const row = document.createElement('div'); + row.classList.add('grid-row'); + + // Add row header (UUID) + const rowHeader = document.createElement('div'); + rowHeader.classList.add('grid-cell', 'header-cell'); + const displayUuid = rowNode.uuid.substring(0, 8) + '...'; + rowHeader.innerHTML = `
${displayUuid}
`; + row.appendChild(rowHeader); + + // Add cells for connections + nodes.forEach((colNode, colIndex) => { + const cell = document.createElement('div'); + cell.classList.add('grid-cell'); + if (rowIndex === colIndex) { + // Diagonal: show node health status + cell.classList.add(`node-${rowNode.health_status}`); + cell.innerHTML = ` +
Status: ${rowNode.health_status.toUpperCase()}
+
+

UUID: ${rowNode.uuid}

+

IP: ${rowNode.ip}

+

Last Seen: ${new Date(rowNode.last_seen).toLocaleTimeString()}

+

Uptime: ${rowNode.uptime_seconds ? formatUptime(rowNode.uptime_seconds) : 'N/A'}

+

Load Avg (1m, 5m, 15m): ${rowNode.load_avg ? rowNode.load_avg.join(', ') : 'N/A'}

+

Memory Usage: ${rowNode.memory_usage_percent ? rowNode.memory_usage_percent.toFixed(2) + '%' : 'N/A'}

+
+ `; + } else { + // Off-diagonal: show ping latency + const latency = rowNode.connections && colNode.uuid in rowNode.connections && rowNode.connections[colNode.uuid] !== null ? rowNode.connections[colNode.uuid] : null; + const displayLatency = latency !== null && !isNaN(latency) ? `${latency.toFixed(1)} ms` : 'N/A'; + const latencyClass = latency !== null && !isNaN(latency) ? getLatencyClass(latency) : 'latency-unavailable'; + cell.classList.add(latencyClass); + cell.innerHTML = ` +
Ping: ${displayLatency}
+
+

From: ${rowNode.uuid}

+

to: ${colNode.uuid}

+

Latency: ${displayLatency}

+
+ `; + } + row.appendChild(cell); + }); + grid.appendChild(row); + }); + + nodeGridContainer.appendChild(grid); + } + + function getLatencyClass(latency) { + if (latency <= 200) return 'latency-low'; + if (latency <= 1000) return 'latency-medium'; + return 'latency-high'; + } + + function createEmptyCell() { + const cell = document.createElement('div'); + cell.classList.add('grid-cell', 'empty-cell'); + return cell; } function formatUptime(seconds) { @@ -56,18 +123,17 @@ document.addEventListener('DOMContentLoaded', () => { const hours = Math.floor(seconds / 3600); seconds %= 3600; const minutes = Math.floor(seconds / 60); - const remainingSeconds = Math.floor(seconds % 60); + const remainingSeconds = seconds % 60; let parts = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); - if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`); // Ensure at least seconds are shown - + if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`); return parts.join(' '); } // Initial fetch and then set up polling fetchNodeData(); setInterval(fetchNodeData, POLLING_INTERVAL_MS); -}); \ No newline at end of file +}); diff --git a/app/web/static/style.css b/app/web/static/style.css index 532d97d..204db45 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -1,156 +1,275 @@ +/* Nord Polar Night Palette */ +:root { + --nord0: #2E3440; /* Darkest background */ + --nord1: #3B4252; /* Dark background */ + --nord2: #434C5E; /* Medium background */ + --nord3: #4C566A; /* Light background */ + --nord4: #D8DEE9; /* Lightest text */ + --nord5: #E5E9F0; /* Light text */ + --nord6: #ECEFF4; /* Brightest text */ + --nord7: #8FBCBB; /* Teal accent */ + --nord8: #88C0D0; /* Cyan accent */ + --nord9: #81A1C1; /* Blue accent (unavailable) */ + --nord10: #5E81AC; /* Darker blue */ + --nord11: #BF616A; /* Red (critical/high latency) */ + --nord12: #D08770; /* Orange (warning/medium latency) */ + --nord13: #A3BE8C; /* Green (healthy/low latency) */ + --nord14: #B48EAD; /* Purple */ + --nord15: #D08770; /* Coral */ +} + body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: flex; - flex-direction: column; /* Changed to column for header + grid */ + flex-direction: column; align-items: center; - min-height: 100vh; /* Use min-height to allow content to push body height */ + min-height: 100vh; margin: 0; - background-color: #f4f7f6; - color: #333; - padding: 20px; /* Add some padding */ - box-sizing: border-box; /* Include padding in element's total width and height */ + background-color: var(--nord0); + color: var(--nord4); + padding: 20px; + box-sizing: border-box; } .header-container { text-align: center; padding: 20px; - background-color: white; + background-color: var(--nord1); border-radius: 8px; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); margin-bottom: 20px; - width: 90%; /* Adjust width */ - max-width: 800px; /* Max width for header */ + width: 80vw; + max-width: 1200px; } h1 { - color: #0b2d48; + color: var(--nord6); margin-bottom: 10px; } p { font-size: 1rem; - color: #555; + color: var(--nord5); margin: 5px 0; } code { - background-color: #e8e8e8; + background-color: var(--nord2); padding: 3px 8px; border-radius: 4px; font-family: "Courier New", Courier, monospace; font-size: 0.9rem; + color: var(--nord6); } -#node-grid-container { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); /* Responsive grid */ - gap: 15px; /* Space between grid items */ - width: 90%; /* Adjust width */ - max-width: 1200px; /* Max width for grid */ +#node-grid-container, #log-table-container { + width: 80vw; + max-width: 1200px; + min-width: 400px; padding: 20px; - background-color: white; + background-color: var(--nord3); border-radius: 8px; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } -.loading-message { - grid-column: 1 / -1; /* Span across all columns */ - text-align: center; - font-style: italic; - color: #888; +.connection-grid { + display: grid; + grid-template-columns: minmax(15%, 1fr) repeat(4, minmax(15%, 1fr)); + gap: 8px; } -.node-cell { - border: 1px solid #ddd; +.grid-row { + display: contents; +} + +.grid-cell { + border: 1px solid var(--nord2); border-radius: 6px; - padding: 15px; + padding: 8px; text-align: center; - font-size: 0.9rem; - box-shadow: 0 2px 5px rgba(0,0,0,0.05); + font-size: clamp(0.8rem, 1.5vw, 0.9rem); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); transition: background-color 0.3s ease, border-color 0.3s ease, transform 0.1s ease; cursor: pointer; - position: relative; /* For tooltip positioning */ - overflow: hidden; /* Hide overflow for truncated UUID */ + position: relative; + overflow: hidden; + min-height: clamp(80px, 15vw, 120px); } -.node-cell:hover { +.grid-cell:hover { transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0,0,0,0.1); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.header-cell { + background-color: var(--nord2); + font-weight: bold; + color: var(--nord6); +} + +.empty-cell { + background-color: transparent; + border: none; + box-shadow: none; } .node-uuid { font-weight: bold; margin-bottom: 5px; - color: #333; + color: var(--nord6); white-space: nowrap; overflow: hidden; - text-overflow: ellipsis; /* Truncate long UUIDs */ + text-overflow: ellipsis; } -.node-status-text { - font-size: 0.8rem; - color: #666; +.node-status-text, .conn-status-text { + font-size: clamp(0.7rem, 1.2vw, 0.8rem); + color: var(--nord5); } /* Health Status Colors */ .node-healthy { - background-color: #e6ffe6; /* Light green */ - border-color: #4CAF50; /* Green */ + background-color: rgba(163, 190, 140, 0.2); /* --nord13 */ + border-color: var(--nord13); } .node-warning { - background-color: #fffacd; /* Light yellow */ - border-color: #FFC107; /* Orange */ + background-color: rgba(208, 135, 112, 0.2); /* --nord12 */ + border-color: var(--nord12); } .node-critical { - background-color: #ffe6e6; /* Light red */ - border-color: #F44336; /* Red */ + background-color: rgba(191, 97, 106, 0.2); /* --nord11 */ + border-color: var(--nord11); } .node-unknown { - background-color: #f0f0f0; /* Light gray */ - border-color: #9E9E9E; /* Gray */ + background-color: rgba(129, 161, 193, 0.2); /* --nord9 */ + border-color: var(--nord9); +} + +/* Latency Colors */ +.latency-low { + background-color: rgba(163, 190, 140, 0.2); /* --nord13, green for <=200ms */ + border-color: var(--nord13); +} + +.latency-medium { + background-color: rgba(208, 135, 112, 0.2); /* --nord12, orange for <=1000ms */ + border-color: var(--nord12); +} + +.latency-high { + background-color: rgba(191, 97, 106, 0.2); /* --nord11, red for >1000ms */ + border-color: var(--nord11); +} + +.latency-unavailable { + background-color: rgba(129, 161, 193, 0.2); /* --nord9, blue for N/A */ + border-color: var(--nord9); } /* Tooltip styles */ .node-tooltip { visibility: hidden; opacity: 0; - width: 200px; - background-color: #333; - color: #fff; + width: clamp(180px, 20vw, 220px); + background-color: var(--nord1); + color: var(--nord4); text-align: left; border-radius: 6px; padding: 10px; position: absolute; z-index: 1; - bottom: 100%; /* Position above the node cell */ + bottom: 100%; left: 50%; - margin-left: -100px; /* Center the tooltip */ + margin-left: clamp(-90px, -10vw, -110px); transition: opacity 0.3s; - font-size: 0.8rem; - white-space: normal; /* Allow text to wrap */ - box-shadow: 0 2px 10px rgba(0,0,0,0.2); + font-size: clamp(0.7rem, 1.2vw, 0.8rem); + white-space: normal; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); } .node-tooltip::after { content: " "; position: absolute; - top: 100%; /* At the bottom of the tooltip */ + top: 100%; left: 50%; margin-left: -5px; border-width: 5px; border-style: solid; - border-color: #333 transparent transparent transparent; + border-color: var(--nord1) transparent transparent transparent; } -.node-cell:hover .node-tooltip { +.grid-cell:hover .node-tooltip { visibility: visible; opacity: 1; } .node-tooltip p { margin: 2px 0; - color: #eee; -} \ No newline at end of file + color: var(--nord4); +} + +/* Log Table Styles */ +.log-table { + width: 100%; + border-collapse: collapse; + background-color: var(--nord2); + border-radius: 6px; + overflow: hidden; +} + +.log-table th, .log-table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid var(--nord3); + font-size: clamp(0.8rem, 1.2vw, 0.9rem); +} + +.log-table th { + background-color: var(--nord1); + color: var(--nord6); + cursor: pointer; +} + +.log-table th.sortable:hover { + background-color: var(--nord10); +} + +.log-table tr:hover { + background-color: var(--nord3); +} + +.log-table .log-level { + font-weight: bold; +} + +.log-level-info { + color: var(--nord13); /* Green */ +} + +.log-level-warning { + color: var(--nord12); /* Orange */ +} + +.log-level-error { + color: var(--nord11); /* Red */ +} + +.json-toggle { + color: var(--nord8); + cursor: pointer; + text-decoration: underline; +} + +.json-content { + background-color: var(--nord1); + padding: 8px; + border-radius: 4px; + font-family: "Courier New", Courier, monospace; + font-size: 0.8rem; + color: var(--nord5); + margin-top: 5px; + white-space: pre-wrap; +} diff --git a/app/web/templates/index.html b/app/web/templates/index.html index 1c96a5f..2a2a446 100644 --- a/app/web/templates/index.html +++ b/app/web/templates/index.html @@ -4,20 +4,19 @@ Node Monitor - +
-

Distributed Node Monitoring System

-

Service ID: {{ service_uuid }}

+

Node Monitoring System

Total Nodes: 0

+
-
- +

Loading node data...

- + - \ No newline at end of file + diff --git a/app/web/templates/logs.html b/app/web/templates/logs.html index bd55976..0cab732 100644 --- a/app/web/templates/logs.html +++ b/app/web/templates/logs.html @@ -1,63 +1,21 @@ - - - Node Monitor - Logs - - - -

Node Monitor Logs - {{ service_uuid }}

-
Auto-refreshing every 5 seconds...
- -
- {% for log in logs %} -
- {{ log.timestamp }} - {{ log.level }} - {{ log.message }} - {% if log.extra %} -
{{ log.extra | tojson }}
- {% endif %} -
- {% endfor %} + + + + + + Node Monitor Logs - {{ service_uuid }} + + + +
+

Node Monitor Logs

+

Service UUID: {{ service_uuid }}

+

Total Logs: 0 (Auto-refreshing every 5 seconds)

- - - +
+

Loading logs...

+
+ +