Major refactoring of the codebase, including restructuring of files and directories, renaming of modules and classes, and improvements to the overall organization and readability of the code. This refactoring aims to enhance maintainability, scalability, and clarity of the codebase while preserving existing functionality. The changes include:
- Restructuring of the project directory into client and server components - Renaming of modules and classes to better reflect their purpose and functionality - Moving common utilities and configurations to a shared location - Updating import statements to reflect the new structure - Adding new documentation files for better clarity on various aspects of the project - Removing deprecated or unused code to streamline the codebase - Ensuring that all existing functionality is preserved and that the codebase remains functional after the refactoring.
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
{% include 'head.html' %}
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.nav {
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
margin-right: 20px;
|
||||
text-decoration: none;
|
||||
color: #0066cc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-card.critical {
|
||||
border-left: 5px solid #f44336;
|
||||
}
|
||||
|
||||
.summary-card.warning {
|
||||
border-left: 5px solid #ff9800;
|
||||
}
|
||||
|
||||
.summary-card.ok {
|
||||
border-left: 5px solid #4caf50;
|
||||
}
|
||||
|
||||
.summary-number {
|
||||
font-size: 3em;
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.summary-number.critical {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.summary-number.warning {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.summary-number.ok {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.9em;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.filter-button:hover {
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.filter-button.active {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.alerts-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
border-left: 5px solid #ddd;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.alert-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.alert-item.critical {
|
||||
border-left-color: #f44336;
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
.alert-item.warning {
|
||||
border-left-color: #ff9800;
|
||||
background: #fff3e0;
|
||||
}
|
||||
|
||||
.alert-item.unknown {
|
||||
border-left-color: #9e9e9e;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.alert-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.alert-level {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.alert-level.critical {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-level.warning {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-level.unknown {
|
||||
background: #9e9e9e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-hostname {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.alert-metric {
|
||||
color: #666;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.alert-details {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.alert-value {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.alert-duration {
|
||||
color: #999;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.no-alerts {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.no-alerts-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #ffebee;
|
||||
border-left: 4px solid #f44336;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.refresh-info {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 0.85em;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.last-update {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
text-align: right;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/live">Live Dashboard</a>
|
||||
<a href="/plugins">Plugin Metrics</a>
|
||||
<a href="/alerts" class="active">Alerts</a>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h1>{{ header }}</h1>
|
||||
<p class="subtitle">Real-time monitoring alerts and threshold violations</p>
|
||||
|
||||
<div class="summary-cards" id="summary-cards">
|
||||
<div class="summary-card critical">
|
||||
<div class="summary-label">Critical</div>
|
||||
<div class="summary-number critical" id="critical-count">-</div>
|
||||
</div>
|
||||
<div class="summary-card warning">
|
||||
<div class="summary-label">Warning</div>
|
||||
<div class="summary-number warning" id="warning-count">-</div>
|
||||
</div>
|
||||
<div class="summary-card ok">
|
||||
<div class="summary-label">Total Hosts</div>
|
||||
<div class="summary-number ok" id="host-count">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<span class="filter-label">Show:</span>
|
||||
<button class="filter-button active" onclick="filterAlerts('all')">All</button>
|
||||
<button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button>
|
||||
<button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button>
|
||||
</div>
|
||||
|
||||
<div class="alerts-container">
|
||||
<div class="last-update">Last updated: <span id="last-update-time">Never</span></div>
|
||||
<div id="alerts-list">
|
||||
<div class="loading">Loading alerts...</div>
|
||||
</div>
|
||||
<div class="refresh-info">
|
||||
Auto-refreshing every 15 seconds
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentFilter = 'all';
|
||||
let allAlerts = [];
|
||||
|
||||
async function loadAlerts() {
|
||||
try {
|
||||
const response = await fetch('/api/0/alerts');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
allAlerts = data.alerts;
|
||||
|
||||
// Update summary cards
|
||||
document.getElementById('critical-count').textContent = data.summary.critical || 0;
|
||||
document.getElementById('warning-count').textContent = data.summary.warning || 0;
|
||||
document.getElementById('host-count').textContent = data.host_count || 0;
|
||||
|
||||
// Update last update time
|
||||
document.getElementById('last-update-time').textContent = new Date().toLocaleTimeString();
|
||||
|
||||
// Render alerts
|
||||
renderAlerts(allAlerts);
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('alerts-list').innerHTML =
|
||||
`<div class="error">Failed to load alerts: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAlerts(alerts) {
|
||||
const container = document.getElementById('alerts-list');
|
||||
|
||||
// Filter alerts based on current filter
|
||||
let filteredAlerts = alerts;
|
||||
if (currentFilter !== 'all') {
|
||||
filteredAlerts = alerts.filter(alert =>
|
||||
alert.level.toLowerCase() === currentFilter
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredAlerts.length === 0) {
|
||||
if (currentFilter === 'all' && alerts.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="no-alerts">
|
||||
<div class="no-alerts-icon">✓</div>
|
||||
<h2>All Systems Normal</h2>
|
||||
<p>No active alerts at this time</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="no-alerts">
|
||||
<p>No ${currentFilter} alerts</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const alert of filteredAlerts) {
|
||||
html += renderAlert(alert);
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderAlert(alert) {
|
||||
const level = alert.level.toLowerCase();
|
||||
const duration = getDuration(alert.since);
|
||||
|
||||
return `
|
||||
<div class="alert-item ${level}">
|
||||
<div class="alert-main">
|
||||
<div class="alert-header">
|
||||
<span class="alert-level ${level}">${alert.level}</span>
|
||||
<span class="alert-hostname">${alert.hostname}</span>
|
||||
</div>
|
||||
<div class="alert-metric">${alert.metric_path}</div>
|
||||
<div class="alert-details">
|
||||
<span>Value: <span class="alert-value">${formatValue(alert.last_value)}</span></span>
|
||||
<span class="alert-duration">Active for ${duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (typeof value === 'number') {
|
||||
if (value > 1000) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
return value.toFixed(2);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getDuration(timestamp) {
|
||||
const now = Date.now() / 1000;
|
||||
const seconds = Math.floor(now - timestamp);
|
||||
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
} else if (seconds < 3600) {
|
||||
return `${Math.floor(seconds / 60)}m`;
|
||||
} else if (seconds < 86400) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterAlerts(filter) {
|
||||
currentFilter = filter;
|
||||
|
||||
// Update active button
|
||||
document.querySelectorAll('.filter-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Re-render with new filter
|
||||
renderAlerts(allAlerts);
|
||||
}
|
||||
|
||||
// Auto-refresh every 15 seconds
|
||||
setInterval(loadAlerts, 15000);
|
||||
|
||||
// Initial load
|
||||
loadAlerts();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<footer>
|
||||
<div id="copyright">
|
||||
©2002-2026 <A HREF="mailto:andreas@wrede.ca">Andreas Wrede</A> All Rights Reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,7 @@
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||
<link rel="stylesheet" href="/static/style.css" type="text/css" />
|
||||
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
|
||||
<title>{{ title }}</title>
|
||||
<script src="{{ extra_scripts }}"></script>
|
||||
</head>
|
||||
@@ -0,0 +1,330 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
{% include 'head.html' %}
|
||||
|
||||
<style>
|
||||
.nav {
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
margin-right: 20px;
|
||||
text-decoration: none;
|
||||
color: #0066cc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table {
|
||||
/* flex: 1; */
|
||||
flex-grow: none;
|
||||
}
|
||||
|
||||
.log {
|
||||
flex: 2;
|
||||
flex-grow: 1;
|
||||
|
||||
}
|
||||
|
||||
#ntable {
|
||||
border-collapse: collapse;
|
||||
font-size: 95%;
|
||||
/* width: 100%; */
|
||||
}
|
||||
|
||||
#ntable td,
|
||||
#ntable th {
|
||||
border: 1px solid #ddd;
|
||||
text-align: left;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
#ntable tr:nth-child(even) {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
|
||||
#ntable tr:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
#ntable th {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
background-color: #9d9d9d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#ntable
|
||||
th:not(.sorttable_sorted):not(.sorttable_sorted_reverse):not(.sorttable_nosort):after {
|
||||
content: " \2195";
|
||||
}
|
||||
|
||||
/* Modal for connection status messages */
|
||||
.connection-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.connection-modal.show {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connection-modal-content {
|
||||
background-color: #f9f9f9;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.connection-modal-content p {
|
||||
margin: 10px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
var cnt = 0;
|
||||
var nTable = document;
|
||||
var name_idx = {};
|
||||
var c = 0;
|
||||
|
||||
function setup() {
|
||||
name_idx = {};
|
||||
nTable = document.getElementById("ntable");
|
||||
for (var i = 0, row; (row = nTable.rows[i]); i++) {
|
||||
if (i == 0) continue;
|
||||
name = nTable.rows[i].cells[0].innerText;
|
||||
name_idx[name] = nTable.rows[i];
|
||||
/* console.log("name_Id[" + name + "]: " + name_idx[name].innerText); */
|
||||
}
|
||||
}
|
||||
|
||||
function createRow(data) {
|
||||
var row = document.createElement("tr");
|
||||
var c_name = document.createElement("td");
|
||||
var c_ver = document.createElement("td");
|
||||
var c_ipv4addr = document.createElement("td");
|
||||
var c_ipv4state = document.createElement("td");
|
||||
var c_ipv4latency = document.createElement("td");
|
||||
c_ipv4latency.style.textAlign = "right";
|
||||
var c_ipv4statets = document.createElement("td");
|
||||
c_ipv4statets.style.textAlign = "right";
|
||||
var c_ipv6addr = document.createElement("td");
|
||||
var c_ipv6state = document.createElement("td");
|
||||
var c_ipv6latency = document.createElement("td");
|
||||
c_ipv6latency.style.textAlign = "right";
|
||||
var c_ipv6statets = document.createElement("td");
|
||||
c_ipv6statets.style.textAlign = "right";
|
||||
row.appendChild(c_name);
|
||||
row.appendChild(c_ver);
|
||||
row.appendChild(c_ipv4addr);
|
||||
row.appendChild(c_ipv4state);
|
||||
row.appendChild(c_ipv4latency);
|
||||
row.appendChild(c_ipv4statets);
|
||||
row.appendChild(c_ipv6addr);
|
||||
row.appendChild(c_ipv6state);
|
||||
row.appendChild(c_ipv6latency);
|
||||
row.appendChild(c_ipv6statets);
|
||||
if (data.dyn) {
|
||||
c_name.innerHTML = "<b>" + data.name + "</b>";
|
||||
} else {
|
||||
c_name.innerHTML = data.name;
|
||||
}
|
||||
c_ver.innerHTML = data.cver;
|
||||
c_ipv4addr.innerHTML = data.connections[0].addr;
|
||||
c_ipv4state.innerHTML = data.connections[0].state;
|
||||
if (data.connections.length > 1) {
|
||||
c_ipv6addr.innerHTML = data.connections[1].addr;
|
||||
c_ipv6state.innerHTML = data.connections[1].state;
|
||||
}
|
||||
var table = document.getElementById("ntablebody"); // find table to append to
|
||||
table.appendChild(row); // append row to table
|
||||
name_idx[c_name] = row;
|
||||
}
|
||||
|
||||
function formatTS(ts) {
|
||||
const milliseconds = ts * 1000;
|
||||
const dateObject = new Date(milliseconds);
|
||||
return dateObject.toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
function update_table(data) {
|
||||
if (!(data.name in name_idx)) {
|
||||
createRow(data);
|
||||
setup();
|
||||
}
|
||||
|
||||
for (var i = 0; i < data.connections.length; i++) {
|
||||
name_idx[data.name].cells[2 + i * 4].innerHTML = data.connections[i].addr;
|
||||
name_idx[data.name].cells[5 + i * 4].innerHTML = formatTS(
|
||||
data.connections[i].statetime
|
||||
);
|
||||
if (data.connections[i].state == "up") {
|
||||
state = "up";
|
||||
latency = Number.parseFloat(data.connections[i].rtts[0]).toFixed(2);
|
||||
} else {
|
||||
if (data.connections[i].state == "unknown") {
|
||||
state = "";
|
||||
latency = "";
|
||||
name_idx[data.name].cells[2 + i * 4].innerHTML = "";
|
||||
name_idx[data.name].cells[5 + i * 4].innerHTML = "";
|
||||
} else {
|
||||
state = "<b>" + data.connections[i].state + "</b>";
|
||||
latency = "-";
|
||||
}
|
||||
}
|
||||
name_idx[data.name].cells[3 + i * 4].innerHTML = state;
|
||||
name_idx[data.name].cells[4 + i * 4].innerHTML = latency;
|
||||
}
|
||||
}
|
||||
|
||||
function WS_Connect() {
|
||||
if ("WebSocket" in window) {
|
||||
//N.B: subprotocol field causes chrome to error 1006
|
||||
var ws_hbd = new WebSocket("{{heartbeat_ws_url}}", /* "hdb" */ );
|
||||
|
||||
ws_hbd.onopen = function () {
|
||||
// Web Socket is connected, send data using send()
|
||||
console.log("ws connect {{heartbeat_ws_url}}");
|
||||
// Hide modal window if visible
|
||||
var modal = document.getElementById("connectionModal");
|
||||
if (modal) {
|
||||
modal.classList.remove("show");
|
||||
}
|
||||
ws_hbd.send("heartbeat_web");
|
||||
};
|
||||
|
||||
ws_hbd.onerror = function (event) {
|
||||
console.log(event);
|
||||
};
|
||||
|
||||
ws_hbd.onmessage = function (event) {
|
||||
/* console.log(event.data); */
|
||||
var state = JSON.parse(event.data);
|
||||
/* console.log("State: " + state.type); */
|
||||
if (state.type == "host") {
|
||||
update_table(state.data);
|
||||
} else if (state.type == "message") {
|
||||
var msgs = document.getElementById("messages");
|
||||
msgs.insertAdjacentHTML("afterbegin", state.data + "<br>");
|
||||
}
|
||||
cnt++;
|
||||
};
|
||||
|
||||
ws_hbd.onclose = function (event) {
|
||||
/* console.log(event); */
|
||||
console.log("Connection is closed, reopening");
|
||||
// Show modal window
|
||||
var modal = document.getElementById("connectionModal");
|
||||
if (modal) {
|
||||
modal.classList.add("show");
|
||||
}
|
||||
setTimeout(function () {
|
||||
WS_Connect();
|
||||
}, 3000);
|
||||
};
|
||||
} else {
|
||||
// The browser doesn't support WebSocket
|
||||
console.log("WebSocket NOT supported by your Browser!");
|
||||
}
|
||||
}
|
||||
WS_Connect();
|
||||
</script>
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/live" class="active">Live Dashboard</a>
|
||||
<a href="/plugins">Plugin Metrics</a>
|
||||
<a href="/alerts">Alerts</a>
|
||||
</div>
|
||||
|
||||
{% include 'menu.html' %}
|
||||
|
||||
<div id="content" class="content" style="overflow: hidden">
|
||||
<div id="table" class="table" style="overflow: hidden">
|
||||
<!-- <h2>{{title}}</h2> -->
|
||||
<table id="ntable" class="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Ver</th>
|
||||
<th>IPv4 Addr</th>
|
||||
<th>State</th>
|
||||
<th style="text-align: right">Latencey</th>
|
||||
<th style="text-align: right">Last State</th>
|
||||
<th>IPv6 Addr</th>
|
||||
<th>State</th>
|
||||
<th style="text-align: right">Latencey</th>
|
||||
<th style="text-align: right">Last State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ntablebody">
|
||||
{% for host in hosts %}
|
||||
<tr>
|
||||
<td>{{ host.name }}</td>
|
||||
<td>{{ host.ver if host.ver else '' }}</td>
|
||||
{% for conn in host.connections %}
|
||||
<td>{{ conn.addr if conn.addr else '' }}</td>
|
||||
<td>{{ conn.state if conn.state else '' }}</td>
|
||||
<td style="text-align: right">{{ conn.latency if conn.latency else '' }}</td>
|
||||
<td style="text-align: right">{{ conn.last_state_ts if conn.last_state_ts else '' }}</td>
|
||||
{% endfor %}
|
||||
{% if host.connections|length == 0 %}
|
||||
<td></td><td></td><td></td><td></td>
|
||||
<td></td><td></td><td></td><td></td>
|
||||
{% elif host.connections|length == 1 %}
|
||||
<td></td><td></td><td></td><td></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="log" class="log" style="overflow: auto;">
|
||||
<h2>Log of Events</h2>
|
||||
<div id="messages">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'foot.html' %}
|
||||
|
||||
<!-- Connection status modal -->
|
||||
<div id="connectionModal" class="connection-modal">
|
||||
<div class="connection-modal-content">
|
||||
<p>⚠️ Connection is closed, reopening...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
setup();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
|
||||
s<header>{{ header }}</header> -->
|
||||
|
||||
@@ -0,0 +1,974 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
{% include 'head.html' %}
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 10px;
|
||||
background: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav {
|
||||
background: #fff;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
margin-right: 20px;
|
||||
text-decoration: none;
|
||||
color: #0066cc;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.host-card {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.host-card.collapsed .host-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.host-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.host-header:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.host-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 1.2em;
|
||||
color: #666;
|
||||
transition: transform 0.2s;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.host-card.collapsed .collapse-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.host-name {
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.host-body {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.plugin-pills {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.plugin-pill {
|
||||
padding: 4px 12px;
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #90caf9;
|
||||
border-radius: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.plugin-pill:hover {
|
||||
background: #90caf9;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plugin-pill.active {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.plugin-content {
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plugin-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #fafafa;
|
||||
border-left: 3px solid #2196f3;
|
||||
padding: 8px 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75em;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.4em;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.metric-unit {
|
||||
font-size: 0.6em;
|
||||
color: #888;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #999;
|
||||
font-size: 0.75em;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #ffebee;
|
||||
border-left: 3px solid #f44336;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 3px;
|
||||
color: #c62828;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.nested-metrics {
|
||||
margin-top: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.nested-header {
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
margin: 8px 0 5px 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.container::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.container::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Table styling for interface data */
|
||||
.interface-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
font-size: 0.85em;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.interface-table thead {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.interface-table th {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75em;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.interface-table th.number {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.interface-table td {
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.interface-table td.number {
|
||||
text-align: right;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.interface-table tbody tr:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.interface-table tbody tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.interface-table tbody tr:nth-child(even):hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.interface-name {
|
||||
font-weight: bold;
|
||||
color: #2196f3;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/live">Live Dashboard</a>
|
||||
<a href="/plugins" class="active">Plugin Metrics</a>
|
||||
<a href="/alerts">Alerts</a>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h1>{{ header }}</h1>
|
||||
<p class="subtitle">Real-time system metrics from monitoring plugins</p>
|
||||
|
||||
{% if not hosts %}
|
||||
<div class="no-data">
|
||||
<p>No hosts with plugin data available</p>
|
||||
<p style="font-size: 0.9em; margin-top: 10px;">Hosts will appear here once they start sending plugin metrics</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="hosts-container">
|
||||
{% for host in hosts %}
|
||||
<div class="host-card" data-hostname="{{ host.name }}">
|
||||
<div class="host-header" onclick="toggleHost('{{ host.name }}')">
|
||||
<div class="host-title">
|
||||
<span class="collapse-icon">▼</span>
|
||||
<span class="host-name">{{ host.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="host-body">
|
||||
<div class="plugin-pills">
|
||||
{% for plugin in host.plugins %}
|
||||
<div class="plugin-pill" data-plugin="{{ plugin }}" onclick="event.stopPropagation(); showPlugin('{{ host.name }}', '{{ plugin }}')">
|
||||
{{ plugin }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% for plugin in host.plugins %}
|
||||
<div class="plugin-content" id="{{ host.name }}-{{ plugin }}" data-hostname="{{ host.name }}" data-plugin="{{ plugin }}">
|
||||
<div class="loading">Loading {{ plugin }} data...</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Track selected plugins per host
|
||||
const selectedPlugins = {};
|
||||
|
||||
function toggleHost(hostname) {
|
||||
const card = document.querySelector(`[data-hostname="${hostname}"]`);
|
||||
card.classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
function showPlugin(hostname, pluginName) {
|
||||
// Update selectedPlugins tracker
|
||||
selectedPlugins[hostname] = pluginName;
|
||||
|
||||
// Update active pill
|
||||
const hostCard = document.querySelector(`[data-hostname="${hostname}"]`);
|
||||
hostCard.querySelectorAll('.plugin-pill').forEach(pill => {
|
||||
pill.classList.remove('active');
|
||||
});
|
||||
hostCard.querySelector(`[data-plugin="${pluginName}"]`).classList.add('active');
|
||||
|
||||
// Show plugin content
|
||||
hostCard.querySelectorAll('.plugin-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
const contentDiv = document.getElementById(`${hostname}-${pluginName}`);
|
||||
contentDiv.classList.add('active');
|
||||
|
||||
// Load data if not already loaded
|
||||
if (contentDiv.querySelector('.loading')) {
|
||||
loadPluginData(hostname, pluginName);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPluginData(hostname, pluginName) {
|
||||
const contentDiv = document.getElementById(`${hostname}-${pluginName}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/0/hosts/${hostname}/plugins/${pluginName}?limit=1`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.samples && data.samples.length > 0) {
|
||||
const sample = data.samples[0];
|
||||
contentDiv.innerHTML = renderPluginData(sample.data, sample.timestamp);
|
||||
} else {
|
||||
contentDiv.innerHTML = '<div class="no-data">No data available for this plugin</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
contentDiv.innerHTML = `<div class="error">Failed to load plugin data: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPluginData(data, timestamp) {
|
||||
let html = '<div class="metric-grid">';
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Skip nested objects for now, handle them separately
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
html += renderMetric(key, value);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Handle nested objects (like partitions in disk_monitor)
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
// Check if this is interface data - render as table
|
||||
if (isInterfaceData(key, value)) {
|
||||
html += renderInterfaceTable(key, value);
|
||||
} else if (isInterfaceStatsData(key, value)) {
|
||||
html += renderInterfaceStatsTable(key, value);
|
||||
} else if (isDiskPartitionData(key, value)) {
|
||||
html += renderPartitionTable(key, value);
|
||||
} else if (isDiskIOData(key, value)) {
|
||||
html += renderDiskIOTable(key, value);
|
||||
} else if (isFilesystemData(key, value)) {
|
||||
html += renderFilesystemTable(key, value);
|
||||
} else {
|
||||
// Regular nested metrics display
|
||||
html += `<div class="nested-metrics">`;
|
||||
html += `<div class="nested-header">📊 ${formatLabel(key)}</div>`;
|
||||
html += '<div class="metric-grid">';
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Handle arrays - more compact display
|
||||
value.forEach((item, idx) => {
|
||||
if (typeof item === 'object') {
|
||||
// Add a compact separator for array items
|
||||
if (idx > 0) {
|
||||
html += `<div style="grid-column: 1/-1; border-top: 1px dashed #ddd; margin: 5px 0;"></div>`;
|
||||
}
|
||||
for (const [subKey, subValue] of Object.entries(item)) {
|
||||
html += renderMetric(`${subKey}`, subValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Handle nested objects
|
||||
for (const [subKey, subValue] of Object.entries(value)) {
|
||||
if (typeof subValue === 'object') {
|
||||
// Another level of nesting - keep compact
|
||||
html += `<div style="grid-column: 1/-1; margin-top: 5px; font-size: 0.85em; color: #666;"><strong>${subKey}:</strong></div>`;
|
||||
for (const [deepKey, deepValue] of Object.entries(subValue)) {
|
||||
html += renderMetric(deepKey, deepValue);
|
||||
}
|
||||
} else {
|
||||
html += renderMetric(subKey, subValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const date = new Date(timestamp * 1000);
|
||||
html += `<div class="timestamp">Last updated: ${date.toLocaleString()}</div>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function isInterfaceData(key, value) {
|
||||
// Check if this is interface/network stats data (I/O counters)
|
||||
if (key.toLowerCase().includes('interface') && !key.toLowerCase().includes('interface_stats')) {
|
||||
// Verify it's an object with interface-like structure
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
// Check if values are objects with byte/packet counters
|
||||
const firstKey = Object.keys(value)[0];
|
||||
if (firstKey && typeof value[firstKey] === 'object') {
|
||||
const sample = value[firstKey];
|
||||
return sample.hasOwnProperty('bytes_sent') ||
|
||||
sample.hasOwnProperty('bytes_recv') ||
|
||||
sample.hasOwnProperty('packets_sent') ||
|
||||
sample.hasOwnProperty('tx_bytes') ||
|
||||
sample.hasOwnProperty('rx_bytes');
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isInterfaceStatsData(key, value) {
|
||||
// Check if this is interface stats data (status, speed, mtu, duplex)
|
||||
if (key.toLowerCase() === 'interface_stats' || key.toLowerCase().includes('if_stats')) {
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
const firstKey = Object.keys(value)[0];
|
||||
if (firstKey && typeof value[firstKey] === 'object') {
|
||||
const sample = value[firstKey];
|
||||
return sample.hasOwnProperty('isup') ||
|
||||
sample.hasOwnProperty('speed') ||
|
||||
sample.hasOwnProperty('mtu') ||
|
||||
sample.hasOwnProperty('duplex');
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDiskPartitionData(key, value) {
|
||||
// Check if this is disk partition data
|
||||
if (key.toLowerCase() === 'partitions' || key.toLowerCase().includes('partition')) {
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
const firstKey = Object.keys(value)[0];
|
||||
if (firstKey && typeof value[firstKey] === 'object') {
|
||||
const sample = value[firstKey];
|
||||
return sample.hasOwnProperty('total') &&
|
||||
sample.hasOwnProperty('used') &&
|
||||
sample.hasOwnProperty('free') &&
|
||||
sample.hasOwnProperty('percent');
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDiskIOData(key, value) {
|
||||
// Check if this is disk I/O counter data
|
||||
if (key.toLowerCase().includes('io_counter') || key.toLowerCase().includes('disk_io')) {
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
const firstKey = Object.keys(value)[0];
|
||||
if (firstKey && typeof value[firstKey] === 'object') {
|
||||
const sample = value[firstKey];
|
||||
return sample.hasOwnProperty('read_bytes') ||
|
||||
sample.hasOwnProperty('write_bytes') ||
|
||||
sample.hasOwnProperty('read_count') ||
|
||||
sample.hasOwnProperty('write_count');
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFilesystemData(key, value) {
|
||||
// Check if this is filesystem info data (from filesystem_info plugin)
|
||||
if (key.toLowerCase() === 'filesystems' && Array.isArray(value)) {
|
||||
if (value.length > 0 && typeof value[0] === 'object') {
|
||||
const sample = value[0];
|
||||
return sample.hasOwnProperty('device') &&
|
||||
sample.hasOwnProperty('mountpoint') &&
|
||||
sample.hasOwnProperty('fstype');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderInterfaceTable(key, interfaces) {
|
||||
let html = `<div class="nested-metrics">`;
|
||||
html += `<div class="nested-header">🌐 ${formatLabel(key)}</div>`;
|
||||
html += '<table class="interface-table">';
|
||||
|
||||
// Determine columns based on available data
|
||||
const sampleInterface = Object.values(interfaces)[0];
|
||||
const hasBytes = sampleInterface.hasOwnProperty('bytes_sent') || sampleInterface.hasOwnProperty('tx_bytes');
|
||||
const hasPackets = sampleInterface.hasOwnProperty('packets_sent') || sampleInterface.hasOwnProperty('tx_packets');
|
||||
const hasErrors = sampleInterface.hasOwnProperty('errin') || sampleInterface.hasOwnProperty('rx_errors');
|
||||
const hasDrops = sampleInterface.hasOwnProperty('dropin') || sampleInterface.hasOwnProperty('rx_dropped');
|
||||
const hasDelta = sampleInterface.hasOwnProperty('bytes_sent_delta');
|
||||
|
||||
// Build table header
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Interface</th>';
|
||||
|
||||
if (hasBytes) {
|
||||
html += '<th class="number">Bytes Sent</th>';
|
||||
html += '<th class="number">Bytes Recv</th>';
|
||||
if (hasDelta) {
|
||||
html += '<th class="number">Δ Sent</th>';
|
||||
html += '<th class="number">Δ Recv</th>';
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPackets) {
|
||||
html += '<th class="number">Pkts Sent</th>';
|
||||
html += '<th class="number">Pkts Recv</th>';
|
||||
if (hasDelta) {
|
||||
html += '<th class="number">Δ Pkts Sent</th>';
|
||||
html += '<th class="number">Δ Pkts Recv</th>';
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
html += '<th class="number">Errors In</th>';
|
||||
html += '<th class="number">Errors Out</th>';
|
||||
}
|
||||
|
||||
if (hasDrops) {
|
||||
html += '<th class="number">Drops In</th>';
|
||||
html += '<th class="number">Drops Out</th>';
|
||||
}
|
||||
|
||||
html += '</tr></thead>';
|
||||
|
||||
// Build table body
|
||||
html += '<tbody>';
|
||||
for (const [ifName, ifData] of Object.entries(interfaces)) {
|
||||
html += '<tr>';
|
||||
html += `<td class="interface-name">${ifName}</td>`;
|
||||
|
||||
if (hasBytes) {
|
||||
html += `<td class="number">${formatBytes(ifData.bytes_sent || ifData.tx_bytes || 0)}</td>`;
|
||||
html += `<td class="number">${formatBytes(ifData.bytes_recv || ifData.rx_bytes || 0)}</td>`;
|
||||
if (hasDelta) {
|
||||
html += `<td class="number">${formatBytes(ifData.bytes_sent_delta || 0)}</td>`;
|
||||
html += `<td class="number">${formatBytes(ifData.bytes_recv_delta || 0)}</td>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPackets) {
|
||||
html += `<td class="number">${(ifData.packets_sent || ifData.tx_packets || 0).toLocaleString()}</td>`;
|
||||
html += `<td class="number">${(ifData.packets_recv || ifData.rx_packets || 0).toLocaleString()}</td>`;
|
||||
if (hasDelta) {
|
||||
html += `<td class="number">${(ifData.packets_sent_delta || 0).toLocaleString()}</td>`;
|
||||
html += `<td class="number">${(ifData.packets_recv_delta || 0).toLocaleString()}</td>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
html += `<td class="number">${ifData.errin || ifData.rx_errors || 0}</td>`;
|
||||
html += `<td class="number">${ifData.errout || ifData.tx_errors || 0}</td>`;
|
||||
}
|
||||
|
||||
if (hasDrops) {
|
||||
html += `<td class="number">${ifData.dropin || ifData.rx_dropped || 0}</td>`;
|
||||
html += `<td class="number">${ifData.dropout || ifData.tx_dropped || 0}</td>`;
|
||||
}
|
||||
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody>';
|
||||
|
||||
html += '</table>';
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderInterfaceStatsTable(key, interfaces) {
|
||||
let html = `<div class="nested-metrics">`;
|
||||
html += `<div class="nested-header">🔌 ${formatLabel(key)}</div>`;
|
||||
html += '<table class="interface-table">';
|
||||
|
||||
// Table header
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Interface</th>';
|
||||
html += '<th>Status</th>';
|
||||
html += '<th class="number">Speed</th>';
|
||||
html += '<th>Duplex</th>';
|
||||
html += '<th class="number">MTU</th>';
|
||||
html += '</tr></thead>';
|
||||
|
||||
// Table body
|
||||
html += '<tbody>';
|
||||
for (const [ifName, ifData] of Object.entries(interfaces)) {
|
||||
html += '<tr>';
|
||||
html += `<td class="interface-name">${ifName}</td>`;
|
||||
|
||||
// Status with color coding
|
||||
const isUp = ifData.isup;
|
||||
const statusColor = isUp ? '#4caf50' : '#f44336';
|
||||
const statusIcon = isUp ? '✓' : '✗';
|
||||
const statusText = isUp ? 'UP' : 'DOWN';
|
||||
html += `<td style="color: ${statusColor}; font-weight: bold;">${statusIcon} ${statusText}</td>`;
|
||||
|
||||
// Speed
|
||||
const speed = ifData.speed || 0;
|
||||
let speedText = '-';
|
||||
if (speed > 0) {
|
||||
if (speed >= 1000) {
|
||||
speedText = (speed / 1000).toFixed(1) + ' Gbps';
|
||||
} else {
|
||||
speedText = speed + ' Mbps';
|
||||
}
|
||||
}
|
||||
html += `<td class="number">${speedText}</td>`;
|
||||
|
||||
// Duplex
|
||||
let duplexText = ifData.duplex || '-';
|
||||
if (duplexText.includes('NicDuplex.')) {
|
||||
duplexText = duplexText.replace('NicDuplex.', '');
|
||||
}
|
||||
if (duplexText === '2') duplexText = 'FULL';
|
||||
if (duplexText === '1') duplexText = 'HALF';
|
||||
if (duplexText === '0') duplexText = 'UNKNOWN';
|
||||
html += `<td>${duplexText}</td>`;
|
||||
|
||||
// MTU
|
||||
html += `<td class="number">${ifData.mtu || '-'}</td>`;
|
||||
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody>';
|
||||
|
||||
html += '</table>';
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderPartitionTable(key, partitions) {
|
||||
let html = `<div class="nested-metrics">`;
|
||||
html += `<div class="nested-header">💾 ${formatLabel(key)}</div>`;
|
||||
html += '<table class="interface-table">';
|
||||
|
||||
// Table header
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Mount Point</th>';
|
||||
html += '<th>Device</th>';
|
||||
html += '<th>Type</th>';
|
||||
html += '<th class="number">Total</th>';
|
||||
html += '<th class="number">Used</th>';
|
||||
html += '<th class="number">Free</th>';
|
||||
html += '<th class="number">Use %</th>';
|
||||
html += '</tr></thead>';
|
||||
|
||||
// Table body
|
||||
html += '<tbody>';
|
||||
for (const [mountPoint, partData] of Object.entries(partitions)) {
|
||||
html += '<tr>';
|
||||
html += `<td class="interface-name">${mountPoint}</td>`;
|
||||
html += `<td>${partData.device || '-'}</td>`;
|
||||
html += `<td>${partData.fstype || '-'}</td>`;
|
||||
html += `<td class="number">${formatBytes(partData.total || 0)}</td>`;
|
||||
html += `<td class="number">${formatBytes(partData.used || 0)}</td>`;
|
||||
html += `<td class="number">${formatBytes(partData.free || 0)}</td>`;
|
||||
|
||||
// Color code the percentage
|
||||
const percent = partData.percent || 0;
|
||||
let percentColor = '#4caf50'; // green
|
||||
if (percent > 90) percentColor = '#f44336'; // red
|
||||
else if (percent > 75) percentColor = '#ff9800'; // orange
|
||||
else if (percent > 50) percentColor = '#ffc107'; // yellow
|
||||
|
||||
html += `<td class="number" style="color: ${percentColor}; font-weight: bold;">${percent.toFixed(1)}%</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody>';
|
||||
|
||||
html += '</table>';
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderDiskIOTable(key, disks) {
|
||||
let html = `<div class="nested-metrics">`;
|
||||
html += `<div class="nested-header">📈 ${formatLabel(key)}</div>`;
|
||||
html += '<table class="interface-table">';
|
||||
|
||||
// Determine columns based on available data
|
||||
const sampleDisk = Object.values(disks)[0];
|
||||
const hasDeltas = sampleDisk.hasOwnProperty('read_bytes_delta');
|
||||
const hasTime = sampleDisk.hasOwnProperty('read_time');
|
||||
|
||||
// Table header
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Disk</th>';
|
||||
html += '<th class="number">Read Bytes</th>';
|
||||
html += '<th class="number">Write Bytes</th>';
|
||||
|
||||
if (hasDeltas) {
|
||||
html += '<th class="number">Δ Read</th>';
|
||||
html += '<th class="number">Δ Write</th>';
|
||||
}
|
||||
|
||||
html += '<th class="number">Read Count</th>';
|
||||
html += '<th class="number">Write Count</th>';
|
||||
|
||||
if (hasDeltas) {
|
||||
html += '<th class="number">Δ Reads</th>';
|
||||
html += '<th class="number">Δ Writes</th>';
|
||||
}
|
||||
|
||||
if (hasTime) {
|
||||
html += '<th class="number">Read Time (ms)</th>';
|
||||
html += '<th class="number">Write Time (ms)</th>';
|
||||
}
|
||||
|
||||
html += '</tr></thead>';
|
||||
|
||||
// Table body
|
||||
html += '<tbody>';
|
||||
for (const [diskName, diskData] of Object.entries(disks)) {
|
||||
html += '<tr>';
|
||||
html += `<td class="interface-name">${diskName}</td>`;
|
||||
html += `<td class="number">${formatBytes(diskData.read_bytes || 0)}</td>`;
|
||||
html += `<td class="number">${formatBytes(diskData.write_bytes || 0)}</td>`;
|
||||
|
||||
if (hasDeltas) {
|
||||
html += `<td class="number">${formatBytes(diskData.read_bytes_delta || 0)}</td>`;
|
||||
html += `<td class="number">${formatBytes(diskData.write_bytes_delta || 0)}</td>`;
|
||||
}
|
||||
|
||||
html += `<td class="number">${(diskData.read_count || 0).toLocaleString()}</td>`;
|
||||
html += `<td class="number">${(diskData.write_count || 0).toLocaleString()}</td>`;
|
||||
|
||||
if (hasDeltas) {
|
||||
html += `<td class="number">${(diskData.read_count_delta || 0).toLocaleString()}</td>`;
|
||||
html += `<td class="number">${(diskData.write_count_delta || 0).toLocaleString()}</td>`;
|
||||
}
|
||||
|
||||
if (hasTime) {
|
||||
html += `<td class="number">${(diskData.read_time || 0).toLocaleString()}</td>`;
|
||||
html += `<td class="number">${(diskData.write_time || 0).toLocaleString()}</td>`;
|
||||
}
|
||||
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody>';
|
||||
|
||||
html += '</table>';
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderFilesystemTable(key, filesystems) {
|
||||
let html = `<div class="nested-metrics">`;
|
||||
html += `<div class="nested-header">🗄️ ${formatLabel(key)}</div>`;
|
||||
html += '<table class="interface-table">';
|
||||
|
||||
// Table header
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Device</th>';
|
||||
html += '<th>Mount Point</th>';
|
||||
html += '<th>Type</th>';
|
||||
html += '<th>Options</th>';
|
||||
html += '<th class="number">Max File</th>';
|
||||
html += '<th class="number">Max Path</th>';
|
||||
html += '</tr></thead>';
|
||||
|
||||
// Table body
|
||||
html += '<tbody>';
|
||||
for (const fs of filesystems) {
|
||||
html += '<tr>';
|
||||
html += `<td class="interface-name">${fs.device || '-'}</td>`;
|
||||
html += `<td>${fs.mountpoint || '-'}</td>`;
|
||||
html += `<td>${fs.fstype || '-'}</td>`;
|
||||
|
||||
// Format mount options - truncate if too long
|
||||
let opts = fs.opts || '-';
|
||||
if (opts.length > 40) {
|
||||
opts = opts.substring(0, 37) + '...';
|
||||
}
|
||||
html += `<td style="font-size: 0.85em;">${opts}</td>`;
|
||||
|
||||
html += `<td class="number">${fs.maxfile || '-'}</td>`;
|
||||
html += `<td class="number">${fs.maxpath || '-'}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody>';
|
||||
|
||||
html += '</table>';
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function renderMetric(key, value) {
|
||||
const label = formatLabel(key);
|
||||
const formattedValue = formatValue(key, value);
|
||||
const unit = getUnit(key);
|
||||
|
||||
return `
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">${label}</div>
|
||||
<div class="metric-value">
|
||||
${formattedValue}
|
||||
${unit ? `<span class="metric-unit">${unit}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatLabel(key) {
|
||||
return key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
function formatValue(key, value) {
|
||||
if (typeof value === 'number') {
|
||||
// Format percentages
|
||||
if (key.includes('percent') || key.includes('usage')) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
// Format bytes to MB/GB
|
||||
if (key.includes('bytes') || key.includes('_mb') || key.includes('_gb')) {
|
||||
if (value > 1073741824) {
|
||||
return (value / 1073741824).toFixed(2);
|
||||
} else if (value > 1048576) {
|
||||
return (value / 1048576).toFixed(2);
|
||||
}
|
||||
}
|
||||
// Default number formatting
|
||||
if (value > 1000) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
return value.toFixed(2);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getUnit(key) {
|
||||
if (key.includes('percent') || key.includes('usage')) return '%';
|
||||
if (key.includes('_gb')) return 'GB';
|
||||
if (key.includes('_mb')) return 'MB';
|
||||
if (key.includes('bytes') && !key.includes('_mb') && !key.includes('_gb')) {
|
||||
// Determine unit based on typical size
|
||||
return 'bytes';
|
||||
}
|
||||
if (key.includes('count')) return '';
|
||||
if (key.includes('mhz')) return 'MHz';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Auto-refresh data every 30 seconds
|
||||
setInterval(() => {
|
||||
for (const [hostname, pluginName] of Object.entries(selectedPlugins)) {
|
||||
const contentDiv = document.getElementById(`${hostname}-${pluginName}`);
|
||||
if (contentDiv && contentDiv.classList.contains('active')) {
|
||||
loadPluginData(hostname, pluginName);
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Initialize by selecting first plugin for each host
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const hostCards = document.querySelectorAll('.host-card');
|
||||
|
||||
hostCards.forEach((card, index) => {
|
||||
const hostname = card.dataset.hostname;
|
||||
const firstPlugin = card.querySelector('.plugin-pill');
|
||||
|
||||
if (firstPlugin) {
|
||||
const pluginName = firstPlugin.dataset.plugin;
|
||||
showPlugin(hostname, pluginName);
|
||||
}
|
||||
|
||||
// Collapse all hosts except the first one
|
||||
if (index > 0) {
|
||||
card.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user