Files
heartbeat/hbd/server/templates/plugins.html
T

1098 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>