1098 lines
34 KiB
HTML
1098 lines
34 KiB
HTML
<!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;
|
||
}
|
||
|
||
/* Simple data table styling (for os_info, cpu_monitor, etc.) */
|
||
.simple-data-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-top: 10px;
|
||
font-size: 0.9em;
|
||
background: #fff;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.simple-data-table thead {
|
||
background: #2196f3;
|
||
color: white;
|
||
}
|
||
|
||
.simple-data-table th {
|
||
padding: 10px 15px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
font-size: 0.8em;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.simple-data-table td {
|
||
padding: 8px 15px;
|
||
border-top: 1px solid #e0e0e0;
|
||
}
|
||
|
||
.simple-data-table td.name {
|
||
font-weight: 500;
|
||
color: #555;
|
||
width: 40%;
|
||
}
|
||
|
||
.simple-data-table td.value {
|
||
color: #333;
|
||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||
}
|
||
|
||
.simple-data-table tbody tr:hover {
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.simple-data-table tbody tr:nth-child(even) {
|
||
background: #fafafa;
|
||
}
|
||
|
||
.simple-data-table tbody tr:nth-child(even):hover {
|
||
background: #f0f0f0;
|
||
}
|
||
</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>`;
|
||
}
|
||
}
|
||
|
||
// Protocol metadata fields injected by the client – never plugin metrics
|
||
const SKIP_FIELDS = new Set(['id', 'name']);
|
||
|
||
function renderPluginData(data, timestamp) {
|
||
// Check if this should be rendered as a simple table
|
||
const pluginName = getCurrentPluginName();
|
||
const simplePlugins = ['os_info', 'cpu_monitor', 'memory_monitor', 'nagios_runner'];
|
||
|
||
if (simplePlugins.includes(pluginName) && isSimpleKeyValueData(data)) {
|
||
return renderSimpleDataTable(data, timestamp);
|
||
}
|
||
|
||
let html = '<div class="metric-grid">';
|
||
|
||
for (const [key, value] of Object.entries(data)) {
|
||
if (SKIP_FIELDS.has(key)) continue;
|
||
// 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 getCurrentPluginName() {
|
||
// Get currently active plugin name from the active plugin content div
|
||
const activeContent = document.querySelector('.plugin-content.active');
|
||
if (activeContent) {
|
||
return activeContent.dataset.plugin;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function isSimpleKeyValueData(data) {
|
||
// Check if data is simple key-value pairs (no complex nesting)
|
||
for (const [key, value] of Object.entries(data)) {
|
||
if (typeof value === 'object' && value !== null) {
|
||
// Has nested objects - not simple
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function renderSimpleDataTable(data, timestamp) {
|
||
let html = '<table class="simple-data-table">';
|
||
|
||
// Table header
|
||
html += '<thead><tr>';
|
||
html += '<th>Name</th>';
|
||
html += '<th>Value</th>';
|
||
html += '</tr></thead>';
|
||
|
||
// Table body
|
||
html += '<tbody>';
|
||
for (const [key, value] of Object.entries(data)) {
|
||
if (SKIP_FIELDS.has(key)) continue;
|
||
const label = formatLabel(key);
|
||
const formattedValue = formatValue(key, value);
|
||
const unit = getUnit(key);
|
||
|
||
html += '<tr>';
|
||
html += `<td class="name">${label}</td>`;
|
||
html += `<td class="value">${formattedValue}${unit ? ' ' + unit : ''}</td>`;
|
||
html += '</tr>';
|
||
}
|
||
html += '</tbody>';
|
||
|
||
html += '</table>';
|
||
|
||
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) {
|
||
if (key === 'time') return 'Collected At';
|
||
return key
|
||
.replace(/_/g, ' ')
|
||
.replace(/\b\w/g, l => l.toUpperCase());
|
||
}
|
||
|
||
function formatValue(key, value) {
|
||
// Epoch timestamp field sent by the client alongside plugin data
|
||
if (key === 'time' && typeof value === 'number') {
|
||
return new Date(value * 1000).toLocaleString();
|
||
}
|
||
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>
|