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
+ ${JSON.stringify(log.extra, null, 2)}
+ ` : '-'}
+ |
+ `;
+ 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()}
-
- `;
- 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()}
+
+ `;
+ } 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}
+
+ `;
+ }
+ 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
-
+
-