Files
logpile/web/static/js/app.js
Michael Chus c243b4e141 Add detailed hardware configuration view with sub-tabs
- Redesign config page with tabs: Spec, CPU, Memory, Power, Storage, GPU, Network, PCIe
- Parse detailed memory info from component.log with all fields:
  Location, Present, Size, Type, Max/Current Speed, Manufacturer, Part Number, Status
- Add GPU model extraction from PCIe devices
- Add NetworkAdapter model with detailed fields from RESTful API
- Update PSU model with power metrics (input/output power, voltage, temperature)
- Memory modules with 0GB size (failed) highlighted in warning color
- Add memory overview stats (total GB, installed count, active count)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 09:11:44 +03:00

594 lines
22 KiB
JavaScript
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.
// LOGPile Frontend Application
document.addEventListener('DOMContentLoaded', () => {
initUpload();
initTabs();
initFilters();
});
// Upload handling
function initUpload() {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
uploadFile(files[0]);
}
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length > 0) {
uploadFile(fileInput.files[0]);
}
});
}
async function uploadFile(file) {
const status = document.getElementById('upload-status');
status.textContent = 'Загрузка и анализ...';
status.className = '';
const formData = new FormData();
formData.append('archive', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
status.innerHTML = `<strong>${escapeHtml(result.vendor)}</strong><br>` +
`${result.stats.sensors} сенсоров, ${result.stats.fru} компонентов, ${result.stats.events} событий`;
status.className = 'success';
loadData(result.vendor);
} else {
status.textContent = result.error || 'Ошибка загрузки';
status.className = 'error';
}
} catch (err) {
status.textContent = 'Ошибка соединения';
status.className = 'error';
}
}
// Tab navigation
function initTabs() {
const tabs = document.querySelectorAll('.tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.dataset.tab).classList.add('active');
});
});
}
// Filters
function initFilters() {
document.getElementById('sensor-filter').addEventListener('change', (e) => {
filterSensors(e.target.value);
});
document.getElementById('severity-filter').addEventListener('change', (e) => {
filterEvents(e.target.value);
});
document.getElementById('serial-filter').addEventListener('change', (e) => {
filterSerials(e.target.value);
});
}
let allSensors = [];
let allEvents = [];
let allSerials = [];
let currentVendor = '';
// Load data from API
async function loadData(vendor) {
currentVendor = vendor || '';
document.getElementById('upload-section').classList.add('hidden');
document.getElementById('data-section').classList.remove('hidden');
document.getElementById('clear-btn').classList.remove('hidden');
// Update vendor badge if exists
const vendorBadge = document.getElementById('vendor-badge');
if (vendorBadge && currentVendor) {
vendorBadge.textContent = currentVendor;
vendorBadge.classList.remove('hidden');
}
await Promise.all([
loadConfig(),
loadFirmware(),
loadSensors(),
loadSerials(),
loadEvents()
]);
}
async function loadConfig() {
try {
const response = await fetch('/api/config');
const config = await response.json();
renderConfig(config);
} catch (err) {
console.error('Failed to load config:', err);
}
}
function renderConfig(data) {
const container = document.getElementById('config-content');
if (!data || Object.keys(data).length === 0) {
container.innerHTML = '<p class="no-data">Нет данных о конфигурации</p>';
return;
}
const config = data.hardware || data;
const spec = data.specification;
let html = '';
// Configuration sub-tabs
html += `<div class="config-tabs">
<button class="config-tab active" data-config-tab="spec">Спецификация</button>
<button class="config-tab" data-config-tab="cpu">CPU</button>
<button class="config-tab" data-config-tab="memory">Memory</button>
<button class="config-tab" data-config-tab="power">Power</button>
<button class="config-tab" data-config-tab="storage">Hard Drive</button>
<button class="config-tab" data-config-tab="gpu">GPU</button>
<button class="config-tab" data-config-tab="network">Network</button>
<button class="config-tab" data-config-tab="pcie">Device Inventory</button>
</div>`;
// Specification tab
html += '<div class="config-tab-content active" id="config-spec">';
if (spec && spec.length > 0) {
html += '<div class="spec-section"><h3>Спецификация сервера</h3><ul class="spec-list">';
spec.forEach(item => {
html += `<li><span class="spec-category">${escapeHtml(item.category)}</span> ${escapeHtml(item.name)} <span class="spec-qty">- ${item.quantity} шт.</span></li>`;
});
html += '</ul></div>';
} else {
html += '<p class="no-data">Нет данных о спецификации</p>';
}
html += '</div>';
// CPU tab
html += '<div class="config-tab-content" id="config-cpu">';
if (config.cpus && config.cpus.length > 0) {
html += '<h3>Процессоры</h3><table class="config-table"><thead><tr><th>Socket</th><th>Модель</th><th>Ядра</th><th>Потоки</th><th>Частота</th><th>Max Turbo</th><th>TDP</th><th>L3 Cache</th><th>PPIN</th></tr></thead><tbody>';
config.cpus.forEach(cpu => {
html += `<tr>
<td>CPU${cpu.socket}</td>
<td>${escapeHtml(cpu.model)}</td>
<td>${cpu.cores}</td>
<td>${cpu.threads}</td>
<td>${cpu.frequency_mhz} MHz</td>
<td>${cpu.max_frequency_mhz} MHz</td>
<td>${cpu.tdp_w}W</td>
<td>${Math.round(cpu.l3_cache_kb/1024)} MB</td>
<td><code>${escapeHtml(cpu.ppin || '-')}</code></td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += '<p class="no-data">Нет данных о процессорах</p>';
}
html += '</div>';
// Memory tab
html += '<div class="config-tab-content" id="config-memory">';
if (config.memory && config.memory.length > 0) {
const totalGB = config.memory.reduce((sum, m) => sum + m.size_mb, 0) / 1024;
const presentCount = config.memory.filter(m => m.present !== false).length;
const workingCount = config.memory.filter(m => m.size_mb > 0).length;
html += `<h3>Модули памяти</h3>
<div class="memory-overview">
<div class="stat-box"><span class="stat-value">${totalGB} GB</span><span class="stat-label">Всего</span></div>
<div class="stat-box"><span class="stat-value">${presentCount}</span><span class="stat-label">Установлено</span></div>
<div class="stat-box"><span class="stat-value">${workingCount}</span><span class="stat-label">Активно</span></div>
</div>
<table class="config-table memory-table"><thead><tr>
<th>Location</th><th>Наличие</th><th>Размер</th><th>Тип</th><th>Max частота</th><th>Текущая частота</th><th>Производитель</th><th>Артикул</th><th>Статус</th>
</tr></thead><tbody>`;
config.memory.forEach(mem => {
const present = mem.present !== false ? '✓' : '-';
const presentClass = mem.present !== false ? 'present-yes' : 'present-no';
const sizeGB = mem.size_mb / 1024;
const statusClass = (mem.status === 'OK' || !mem.status) ? '' : 'status-warning';
const rowClass = sizeGB === 0 ? 'row-warning' : '';
html += `<tr class="${rowClass}">
<td>${escapeHtml(mem.location || mem.slot)}</td>
<td class="${presentClass}">${present}</td>
<td>${sizeGB} GB</td>
<td>${escapeHtml(mem.type || '-')}</td>
<td>${mem.max_speed_mhz || '-'} MHz</td>
<td>${mem.current_speed_mhz || mem.speed_mhz || '-'} MHz</td>
<td>${escapeHtml(mem.manufacturer || '-')}</td>
<td><code>${escapeHtml(mem.part_number || '-')}</code></td>
<td class="${statusClass}">${escapeHtml(mem.status || 'OK')}</td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += '<p class="no-data">Нет данных о памяти</p>';
}
html += '</div>';
// Power tab
html += '<div class="config-tab-content" id="config-power">';
if (config.power_supplies && config.power_supplies.length > 0) {
html += '<h3>Блоки питания</h3><table class="config-table"><thead><tr><th>Слот</th><th>Производитель</th><th>Модель</th><th>Мощность</th><th>Вход</th><th>Выход</th><th>Напряжение</th><th>Температура</th><th>Статус</th></tr></thead><tbody>';
config.power_supplies.forEach(psu => {
const statusClass = psu.status === 'OK' ? '' : 'status-warning';
html += `<tr>
<td>${escapeHtml(psu.slot)}</td>
<td>${escapeHtml(psu.vendor || '-')}</td>
<td>${escapeHtml(psu.model || '-')}</td>
<td>${psu.wattage_w || '-'}W</td>
<td>${psu.input_power_w || '-'}W</td>
<td>${psu.output_power_w || '-'}W</td>
<td>${psu.input_voltage ? psu.input_voltage.toFixed(0) : '-'}V</td>
<td>${psu.temperature_c || '-'}°C</td>
<td class="${statusClass}">${escapeHtml(psu.status || '-')}</td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += '<p class="no-data">Нет данных о блоках питания</p>';
}
html += '</div>';
// Storage tab
html += '<div class="config-tab-content" id="config-storage">';
if (config.storage && config.storage.length > 0) {
html += '<h3>Накопители</h3><table class="config-table"><thead><tr><th>Слот</th><th>Тип</th><th>Интерфейс</th><th>Модель</th><th>Производитель</th><th>Размер</th><th>Серийный номер</th></tr></thead><tbody>';
config.storage.forEach(s => {
html += `<tr>
<td>${escapeHtml(s.slot || '-')}</td>
<td>${escapeHtml(s.type || '-')}</td>
<td>${escapeHtml(s.interface || '-')}</td>
<td>${escapeHtml(s.model || '-')}</td>
<td>${escapeHtml(s.manufacturer || '-')}</td>
<td>${s.size_gb} GB</td>
<td><code>${escapeHtml(s.serial_number || '-')}</code></td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += '<p class="no-data">Нет данных о накопителях</p>';
}
html += '</div>';
// GPU tab
html += '<div class="config-tab-content" id="config-gpu">';
if (config.gpus && config.gpus.length > 0) {
html += '<h3>Графические процессоры</h3><table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>BDF</th><th>PCIe</th><th>Серийный номер</th></tr></thead><tbody>';
config.gpus.forEach(gpu => {
html += `<tr>
<td>${escapeHtml(gpu.slot || '-')}</td>
<td>${escapeHtml(gpu.model || '-')}</td>
<td>${escapeHtml(gpu.manufacturer || '-')}</td>
<td><code>${escapeHtml(gpu.bdf || '-')}</code></td>
<td>x${gpu.link_width || '-'} ${escapeHtml(gpu.link_speed || '-')}</td>
<td><code>${escapeHtml(gpu.serial_number || '-')}</code></td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += '<p class="no-data">Нет GPU</p>';
}
html += '</div>';
// Network tab
html += '<div class="config-tab-content" id="config-network">';
if (config.network_adapters && config.network_adapters.length > 0) {
html += '<h3>Сетевые адаптеры</h3><table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>Порты</th><th>Тип</th><th>MAC адреса</th><th>Статус</th></tr></thead><tbody>';
config.network_adapters.forEach(nic => {
const macs = nic.mac_addresses ? nic.mac_addresses.join(', ') : '-';
const statusClass = nic.status === 'OK' ? '' : 'status-warning';
html += `<tr>
<td>${escapeHtml(nic.location || nic.slot || '-')}</td>
<td>${escapeHtml(nic.model || '-')}</td>
<td>${escapeHtml(nic.vendor || '-')}</td>
<td>${nic.port_count || '-'}</td>
<td>${escapeHtml(nic.port_type || '-')}</td>
<td><code>${escapeHtml(macs)}</code></td>
<td class="${statusClass}">${escapeHtml(nic.status || '-')}</td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += '<p class="no-data">Нет данных о сетевых адаптерах</p>';
}
html += '</div>';
// PCIe Device Inventory tab
html += '<div class="config-tab-content" id="config-pcie">';
if (config.pcie_devices && config.pcie_devices.length > 0) {
html += '<h3>PCIe устройства</h3><table class="config-table"><thead><tr><th>Слот</th><th>BDF</th><th>Тип</th><th>Производитель</th><th>Vendor:Device ID</th><th>PCIe Link</th></tr></thead><tbody>';
config.pcie_devices.forEach(p => {
html += `<tr>
<td>${escapeHtml(p.slot || '-')}</td>
<td><code>${escapeHtml(p.bdf || '-')}</code></td>
<td>${escapeHtml(p.device_class || '-')}</td>
<td>${escapeHtml(p.manufacturer || '-')}</td>
<td><code>${p.vendor_id ? p.vendor_id.toString(16) : '-'}:${p.device_id ? p.device_id.toString(16) : '-'}</code></td>
<td>x${p.link_width || '-'} ${escapeHtml(p.link_speed || '-')}</td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += '<p class="no-data">Нет данных о PCIe устройствах</p>';
}
html += '</div>';
container.innerHTML = html;
// Initialize config sub-tabs
initConfigTabs();
}
function initConfigTabs() {
const tabs = document.querySelectorAll('.config-tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
document.querySelectorAll('.config-tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById('config-' + tab.dataset.configTab).classList.add('active');
});
});
}
async function loadFirmware() {
try {
const response = await fetch('/api/firmware');
const firmware = await response.json();
renderFirmware(firmware);
} catch (err) {
console.error('Failed to load firmware:', err);
}
}
function renderFirmware(firmware) {
const tbody = document.querySelector('#firmware-table tbody');
tbody.innerHTML = '';
if (!firmware || firmware.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="no-data">Нет данных о прошивках</td></tr>';
return;
}
firmware.forEach(fw => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${escapeHtml(fw.device_name)}</td>
<td><code>${escapeHtml(fw.version)}</code></td>
<td>${escapeHtml(fw.build_date || '-')}</td>
`;
tbody.appendChild(row);
});
}
async function loadSensors() {
try {
const response = await fetch('/api/sensors');
allSensors = await response.json();
renderSensors(allSensors);
} catch (err) {
console.error('Failed to load sensors:', err);
}
}
function renderSensors(sensors) {
const container = document.getElementById('sensors-content');
if (!sensors || sensors.length === 0) {
container.innerHTML = '<p class="no-data">Нет данных о сенсорах</p>';
return;
}
// Group by type
const byType = {};
sensors.forEach(s => {
if (!byType[s.type]) byType[s.type] = [];
byType[s.type].push(s);
});
const typeNames = {
temperature: 'Температура',
voltage: 'Напряжение',
power: 'Мощность',
fan_speed: 'Вентиляторы',
fan_status: 'Статус вентиляторов',
psu_status: 'Статус БП',
cpu_status: 'Статус CPU',
storage_status: 'Статус накопителей',
other: 'Прочее'
};
let html = '';
for (const [type, items] of Object.entries(byType)) {
html += `<div class="sensor-group" data-type="${type}">
<h3>${typeNames[type] || type}</h3>
<div class="sensor-grid">`;
items.forEach(s => {
let valueStr = '';
let statusClass = s.status === 'ok' ? 'ok' : (s.status === 'ns' ? 'ns' : 'warn');
if (s.value) {
valueStr = `${s.value} ${s.unit}`;
} else if (s.raw_value) {
valueStr = s.raw_value;
} else {
valueStr = s.status;
}
html += `<div class="sensor-card ${statusClass}">
<span class="sensor-name">${escapeHtml(s.name)}</span>
<span class="sensor-value">${escapeHtml(valueStr)}</span>
</div>`;
});
html += '</div></div>';
}
container.innerHTML = html;
}
function filterSensors(type) {
if (!type) {
renderSensors(allSensors);
return;
}
const filtered = allSensors.filter(s => s.type === type);
renderSensors(filtered);
}
async function loadSerials() {
try {
const response = await fetch('/api/serials');
allSerials = await response.json();
renderSerials(allSerials);
} catch (err) {
console.error('Failed to load serials:', err);
}
}
function renderSerials(serials) {
const tbody = document.querySelector('#serials-table tbody');
tbody.innerHTML = '';
if (!serials || serials.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="no-data">Нет серийных номеров</td></tr>';
return;
}
const categoryNames = {
'Board': 'Мат. плата',
'CPU': 'Процессор',
'Memory': 'Память',
'Storage': 'Накопитель',
'PCIe': 'PCIe',
'Network': 'Сеть',
'PSU': 'БП',
'FRU': 'FRU'
};
serials.forEach(item => {
if (!item.serial_number) return;
const row = document.createElement('tr');
row.innerHTML = `
<td><span class="category-badge ${item.category.toLowerCase()}">${categoryNames[item.category] || item.category}</span></td>
<td>${escapeHtml(item.component)}</td>
<td><code>${escapeHtml(item.serial_number)}</code></td>
<td>${escapeHtml(item.manufacturer || '-')}</td>
<td>${escapeHtml(item.part_number || '-')}</td>
`;
tbody.appendChild(row);
});
}
function filterSerials(category) {
if (!category) {
renderSerials(allSerials);
return;
}
const filtered = allSerials.filter(s => s.category === category);
renderSerials(filtered);
}
async function loadEvents() {
try {
const response = await fetch('/api/events');
allEvents = await response.json();
renderEvents(allEvents);
} catch (err) {
console.error('Failed to load events:', err);
}
}
function renderEvents(events) {
const tbody = document.querySelector('#events-table tbody');
tbody.innerHTML = '';
if (!events || events.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="no-data">Нет событий</td></tr>';
return;
}
events.forEach(event => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${formatDate(event.timestamp)}</td>
<td>${escapeHtml(event.source)}</td>
<td>${escapeHtml(event.description)}</td>
<td><span class="severity ${event.severity}">${event.severity}</span></td>
`;
tbody.appendChild(row);
});
}
function filterEvents(severity) {
if (!severity) {
renderEvents(allEvents);
return;
}
const filtered = allEvents.filter(e => e.severity === severity);
renderEvents(filtered);
}
// Export functions
function exportData(format) {
window.location.href = `/api/export/${format}`;
}
// Clear data
async function clearData() {
try {
await fetch('/api/clear', { method: 'DELETE' });
document.getElementById('upload-section').classList.remove('hidden');
document.getElementById('data-section').classList.add('hidden');
document.getElementById('clear-btn').classList.add('hidden');
document.getElementById('upload-status').textContent = '';
allSensors = [];
allEvents = [];
allSerials = [];
} catch (err) {
console.error('Failed to clear data:', err);
}
}
// Utilities
function formatDate(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
return date.toLocaleString('ru-RU');
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}