Files
logpile/web/static/js/app.js
2026-02-04 19:43:51 +03:00

1266 lines
48 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', () => {
initSourceType();
initApiSource();
initUpload();
initTabs();
initFilters();
loadParsersInfo();
});
let sourceType = 'archive';
let apiConnectPayload = null;
let collectionJob = null;
let collectionJobPollTimer = null;
let collectionJobLogCounter = 0;
let apiPortTouchedByUser = false;
let isAutoUpdatingApiPort = false;
function initSourceType() {
const sourceButtons = document.querySelectorAll('.source-switch-btn');
sourceButtons.forEach(button => {
button.addEventListener('click', () => {
setSourceType(button.dataset.sourceType);
});
});
setSourceType(sourceType);
}
function setSourceType(nextType) {
sourceType = nextType === 'api' ? 'api' : 'archive';
document.querySelectorAll('.source-switch-btn').forEach(button => {
button.classList.toggle('active', button.dataset.sourceType === sourceType);
});
const archiveContent = document.getElementById('archive-source-content');
const apiSourceContent = document.getElementById('api-source-content');
archiveContent.classList.toggle('hidden', sourceType !== 'archive');
apiSourceContent.classList.toggle('hidden', sourceType !== 'api');
}
function initApiSource() {
const apiForm = document.getElementById('api-connect-form');
if (!apiForm) {
return;
}
const cancelJobButton = document.getElementById('cancel-job-btn');
const fieldNames = ['host', 'port', 'username', 'password'];
apiForm.addEventListener('submit', (event) => {
event.preventDefault();
const { isValid, payload, errors } = validateCollectForm();
renderFormErrors(errors);
if (!isValid) {
renderApiConnectStatus(false, null);
apiConnectPayload = null;
return;
}
apiConnectPayload = payload;
renderApiConnectStatus(true, payload);
startCollectionJob(payload);
});
if (cancelJobButton) {
cancelJobButton.addEventListener('click', () => {
cancelCollectionJob();
});
}
fieldNames.forEach((fieldName) => {
const field = apiForm.elements.namedItem(fieldName);
if (!field) {
return;
}
const eventName = field.tagName.toLowerCase() === 'select' ? 'change' : 'input';
field.addEventListener(eventName, () => {
if (fieldName === 'port') {
handleApiPortInput(field.value);
}
const { errors } = validateCollectForm();
renderFormErrors(errors);
clearApiConnectStatus();
if (collectionJob && isCollectionJobTerminal(collectionJob.status)) {
resetCollectionJobState();
}
});
});
applyRedfishDefaultPort();
renderCollectionJob();
}
function validateCollectForm() {
const host = getApiValue('host');
const portRaw = getApiValue('port');
const username = getApiValue('username');
const password = getApiValue('password');
const errors = {};
if (!host) {
errors.host = 'Укажите host.';
}
const port = Number(portRaw);
const isPortInteger = Number.isInteger(port);
if (!portRaw) {
errors.port = 'Укажите порт.';
} else if (!isPortInteger || port < 1 || port > 65535) {
errors.port = 'Порт должен быть от 1 до 65535.';
}
if (!username) {
errors.username = 'Укажите username.';
}
if (!password) {
errors.password = 'Введите пароль.';
}
if (Object.keys(errors).length > 0) {
return { isValid: false, errors, payload: null };
}
// TODO: UI для выбора протокола вернем, когда откроем IPMI коннектор.
const payload = {
host,
protocol: 'redfish',
port,
username,
auth_type: 'password',
tls_mode: 'insecure',
password
};
return { isValid: true, errors: {}, payload };
}
function renderFormErrors(errors) {
const apiForm = document.getElementById('api-connect-form');
const summary = document.getElementById('api-form-errors');
if (!apiForm || !summary) {
return;
}
const errorFields = ['host', 'port', 'username', 'password'];
errorFields.forEach((fieldName) => {
const errorNode = apiForm.querySelector(`[data-error-for="${fieldName}"]`);
if (!errorNode) {
return;
}
const fieldWrapper = errorNode.closest('.api-form-field');
const message = errors[fieldName] || '';
errorNode.textContent = message;
if (fieldWrapper) {
fieldWrapper.classList.toggle('has-error', Boolean(message));
}
});
const messages = Object.values(errors);
if (messages.length === 0) {
summary.innerHTML = '';
summary.classList.add('hidden');
return;
}
summary.classList.remove('hidden');
summary.innerHTML = `<strong>Исправьте ошибки в форме:</strong><ul>${messages.map(msg => `<li>${escapeHtml(msg)}</li>`).join('')}</ul>`;
}
function renderApiConnectStatus(isValid, payload) {
const status = document.getElementById('api-connect-status');
if (!status) {
return;
}
if (!isValid) {
status.textContent = 'Форма не отправлена: есть ошибки.';
status.className = 'api-connect-status error';
return;
}
const payloadPreview = { ...payload };
if (payloadPreview.password) {
payloadPreview.password = '***';
}
if (payloadPreview.token) {
payloadPreview.token = '***';
}
status.textContent = `Payload сформирован: ${JSON.stringify(payloadPreview)}`;
status.className = 'api-connect-status success';
}
function clearApiConnectStatus() {
const status = document.getElementById('api-connect-status');
if (!status) {
return;
}
status.textContent = '';
status.className = 'api-connect-status';
}
function startCollectionJob(payload) {
resetCollectionJobState();
setApiFormBlocked(true);
fetch('/api/collect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(async (response) => {
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error || 'Не удалось запустить задачу');
}
collectionJob = {
id: body.job_id,
status: normalizeJobStatus(body.status || 'queued'),
progress: 0,
logs: [],
payload
};
appendJobLog(body.message || 'Задача поставлена в очередь');
renderCollectionJob();
collectionJobPollTimer = window.setInterval(() => {
pollCollectionJobStatus();
}, 1200);
})
.catch((err) => {
setApiFormBlocked(false);
clearApiConnectStatus();
renderApiConnectStatus(false, null);
const status = document.getElementById('api-connect-status');
if (status) {
status.textContent = err.message || 'Ошибка запуска задачи';
status.className = 'api-connect-status error';
}
});
}
function pollCollectionJobStatus() {
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
clearCollectionJobPolling();
return;
}
fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}`)
.then(async (response) => {
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error || 'Не удалось получить статус задачи');
}
const prevStatus = collectionJob.status;
collectionJob.status = normalizeJobStatus(body.status || collectionJob.status);
collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress;
collectionJob.error = body.error || '';
syncServerLogs(body.logs);
renderCollectionJob();
if (isCollectionJobTerminal(collectionJob.status)) {
clearCollectionJobPolling();
if (collectionJob.status === 'success') {
loadDataFromStatus();
} else if (collectionJob.status === 'failed' && collectionJob.error) {
appendJobLog(`Ошибка: ${collectionJob.error}`);
renderCollectionJob();
}
} else if (prevStatus !== collectionJob.status && collectionJob.status === 'running') {
appendJobLog('Сбор выполняется...');
renderCollectionJob();
}
})
.catch((err) => {
appendJobLog(`Ошибка статуса: ${err.message}`);
renderCollectionJob();
clearCollectionJobPolling();
setApiFormBlocked(false);
});
}
function cancelCollectionJob() {
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
return;
}
fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}/cancel`, {
method: 'POST'
})
.then(async (response) => {
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error || 'Не удалось отменить задачу');
}
collectionJob.status = normalizeJobStatus(body.status || 'canceled');
collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress;
syncServerLogs(body.logs);
clearCollectionJobPolling();
renderCollectionJob();
})
.catch((err) => {
appendJobLog(`Ошибка отмены: ${err.message}`);
renderCollectionJob();
});
}
function appendJobLog(message) {
if (!collectionJob) {
return;
}
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
collectionJob.logs.push({
id: ++collectionJobLogCounter,
time,
message
});
}
function renderCollectionJob() {
const jobStatusBlock = document.getElementById('api-job-status');
const jobIdValue = document.getElementById('job-id-value');
const statusValue = document.getElementById('job-status-value');
const progressValue = document.getElementById('job-progress-value');
const logsList = document.getElementById('job-logs-list');
const cancelButton = document.getElementById('cancel-job-btn');
if (!jobStatusBlock || !jobIdValue || !statusValue || !progressValue || !logsList || !cancelButton) {
return;
}
if (!collectionJob) {
jobStatusBlock.classList.add('hidden');
setApiFormBlocked(false);
return;
}
jobStatusBlock.classList.remove('hidden');
jobIdValue.textContent = collectionJob.id;
statusValue.textContent = collectionJob.status;
statusValue.className = `job-status-badge status-${collectionJob.status.toLowerCase()}`;
const isTerminal = isCollectionJobTerminal(collectionJob.status);
const terminalMessage = {
success: 'Сбор завершен',
failed: 'Сбор завершился ошибкой',
canceled: 'Сбор отменен'
}[collectionJob.status];
const progressLabel = isTerminal
? terminalMessage
: 'Сбор данных...';
progressValue.textContent = `${collectionJob.progress}% · ${progressLabel}`;
logsList.innerHTML = collectionJob.logs.map((log) => (
`<li><span class="log-time">${escapeHtml(log.time)}</span><span class="log-message">${escapeHtml(log.message)}</span></li>`
)).join('');
cancelButton.disabled = isTerminal;
setApiFormBlocked(!isTerminal);
}
function isCollectionJobTerminal(status) {
return ['success', 'failed', 'canceled'].includes(normalizeJobStatus(status));
}
function setApiFormBlocked(shouldBlock) {
const apiForm = document.getElementById('api-connect-form');
if (!apiForm) {
return;
}
apiForm.classList.toggle('is-disabled', shouldBlock);
Array.from(apiForm.elements).forEach((field) => {
field.disabled = shouldBlock;
});
}
function clearCollectionJobPolling() {
if (!collectionJobPollTimer) {
return;
}
window.clearInterval(collectionJobPollTimer);
collectionJobPollTimer = null;
}
function resetCollectionJobState() {
clearCollectionJobPolling();
collectionJob = null;
renderCollectionJob();
}
function syncServerLogs(logs) {
if (!collectionJob || !Array.isArray(logs)) {
return;
}
if (logs.length <= collectionJob.logs.length) {
return;
}
const from = collectionJob.logs.length;
for (let i = from; i < logs.length; i += 1) {
appendJobLog(logs[i]);
}
}
function normalizeJobStatus(status) {
return String(status || '').trim().toLowerCase();
}
async function loadDataFromStatus() {
try {
const response = await fetch('/api/status');
const payload = await response.json();
if (!payload.loaded) {
return;
}
const vendor = payload.vendor || payload.protocol || '';
const filename = payload.filename || (payload.protocol && payload.target_host
? `${payload.protocol}://${payload.target_host}`
: '');
await loadData(vendor, filename);
} catch (err) {
console.error('Failed to load data after collect:', err);
}
}
function applyRedfishDefaultPort() {
const apiForm = document.getElementById('api-connect-form');
if (!apiForm) {
return;
}
const portField = apiForm.elements.namedItem('port');
if (!portField || typeof portField.value !== 'string') {
return;
}
const currentValue = portField.value.trim();
if (apiPortTouchedByUser && currentValue !== '') {
return;
}
isAutoUpdatingApiPort = true;
portField.value = '443';
isAutoUpdatingApiPort = false;
}
function handleApiPortInput(value) {
if (isAutoUpdatingApiPort) {
return;
}
apiPortTouchedByUser = value.trim() !== '';
}
function getApiValue(fieldName) {
const apiForm = document.getElementById('api-connect-form');
if (!apiForm) {
return '';
}
const field = apiForm.elements.namedItem(fieldName);
if (!field || typeof field.value !== 'string') {
return '';
}
return field.value.trim();
}
// Load and display available parsers
async function loadParsersInfo() {
try {
const response = await fetch('/api/parsers');
const data = await response.json();
const container = document.getElementById('parsers-info');
if (data.parsers && data.parsers.length > 0) {
let html = '<p class="parsers-title">Поддерживаемые платформы:</p><div class="parsers-list">';
data.parsers.forEach(p => {
html += `<div class="parser-item">
<span class="parser-name">${escapeHtml(p.name)}</span>
<span class="parser-version">v${escapeHtml(p.version)}</span>
</div>`;
});
html += '</div>';
container.innerHTML = html;
}
} catch (err) {
console.error('Failed to load parsers info:', err);
}
}
// 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, result.filename);
} 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, filename) {
currentVendor = vendor || '';
document.getElementById('upload-section').classList.add('hidden');
document.getElementById('data-section').classList.remove('hidden');
document.getElementById('clear-btn').classList.remove('hidden');
// Update parser name and filename
const parserName = document.getElementById('parser-name');
const fileNameElem = document.getElementById('file-name');
if (parserName && currentVendor) {
parserName.textContent = currentVendor;
}
if (fileNameElem && filename) {
fileNameElem.textContent = filename;
}
// Update vendor badge if exists (legacy support)
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 = '';
// Server info header
if (config.board) {
html += `<div class="server-info">
<div class="server-info-item"><span class="server-info-label">Модель сервера:</span> <strong>${escapeHtml(config.board.product_name || '-')}</strong></div>
<div class="server-info-item"><span class="server-info-label">Серийный номер:</span> <code>${escapeHtml(config.board.serial_number || '-')}</code></div>
</div>`;
}
// 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) {
const cpuCount = config.cpus.length;
const cpuModel = config.cpus[0].model || '-';
const totalCores = config.cpus.reduce((sum, c) => sum + (c.cores || 0), 0);
const totalThreads = config.cpus.reduce((sum, c) => sum + (c.threads || 0), 0);
html += `<h3>Процессоры</h3>
<div class="section-overview">
<div class="stat-box"><span class="stat-value">${cpuCount}</span><span class="stat-label">Процессоров</span></div>
<div class="stat-box"><span class="stat-value">${totalCores}</span><span class="stat-label">Ядер</span></div>
<div class="stat-box"><span class="stat-value">${totalThreads}</span><span class="stat-label">Потоков</span></div>
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(cpuModel)}</span><span class="stat-label">Модель</span></div>
</div>
<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) {
const psuTotal = config.power_supplies.length;
const psuPresent = config.power_supplies.filter(p => p.present !== false).length;
const psuOK = config.power_supplies.filter(p => p.status === 'OK').length;
const psuModel = config.power_supplies[0].model || '-';
const psuWattage = config.power_supplies[0].wattage_w || 0;
html += `<h3>Блоки питания</h3>
<div class="section-overview">
<div class="stat-box"><span class="stat-value">${psuTotal}</span><span class="stat-label">Всего</span></div>
<div class="stat-box"><span class="stat-value">${psuPresent}</span><span class="stat-label">Подключено</span></div>
<div class="stat-box"><span class="stat-value">${psuOK}</span><span class="stat-label">Работает</span></div>
<div class="stat-box"><span class="stat-value">${psuWattage}W</span><span class="stat-label">Мощность</span></div>
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(psuModel)}</span><span class="stat-label">Модель</span></div>
</div>
<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) {
const storTotal = config.storage.length;
const storHDD = config.storage.filter(s => s.type === 'HDD').length;
const storSSD = config.storage.filter(s => s.type === 'SSD').length;
const storNVMe = config.storage.filter(s => s.type === 'NVMe').length;
const totalTB = (config.storage.reduce((sum, s) => sum + (s.size_gb || 0), 0) / 1000).toFixed(1);
let typesSummary = [];
if (storHDD > 0) typesSummary.push(`${storHDD} HDD`);
if (storSSD > 0) typesSummary.push(`${storSSD} SSD`);
if (storNVMe > 0) typesSummary.push(`${storNVMe} NVMe`);
html += `<h3>Накопители</h3>
<div class="section-overview">
<div class="stat-box"><span class="stat-value">${storTotal}</span><span class="stat-label">Всего слотов</span></div>
<div class="stat-box"><span class="stat-value">${config.storage.filter(s => s.present).length}</span><span class="stat-label">Установлено</span></div>
<div class="stat-box"><span class="stat-value">${totalTB > 0 ? totalTB + ' TB' : '-'}</span><span class="stat-label">Объём</span></div>
<div class="stat-box model-box"><span class="stat-value">${typesSummary.join(', ') || '-'}</span><span class="stat-label">По типам</span></div>
</div>
<table class="config-table"><thead><tr><th>NO.</th><th>Статус</th><th>Расположение</th><th>Backplane ID</th><th>Тип</th><th>Модель</th><th>Размер</th><th>Серийный номер</th></tr></thead><tbody>`;
config.storage.forEach(s => {
const presentIcon = s.present ? '<span style="color: #27ae60;">●</span>' : '<span style="color: #95a5a6;">○</span>';
const presentText = s.present ? 'Present' : 'Empty';
html += `<tr>
<td>${escapeHtml(s.slot || '-')}</td>
<td>${presentIcon} ${presentText}</td>
<td>${escapeHtml(s.location || '-')}</td>
<td>${s.backplane_id !== undefined ? s.backplane_id : '-'}</td>
<td>${escapeHtml(s.type || '-')}</td>
<td>${escapeHtml(s.model || '-')}</td>
<td>${s.size_gb > 0 ? s.size_gb + ' GB' : '-'}</td>
<td>${s.serial_number ? '<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) {
const gpuCount = config.gpus.length;
const gpuModel = config.gpus[0].model || '-';
const gpuVendor = config.gpus[0].manufacturer || '-';
html += `<h3>Графические процессоры</h3>
<div class="section-overview">
<div class="stat-box"><span class="stat-value">${gpuCount}</span><span class="stat-label">Всего GPU</span></div>
<div class="stat-box"><span class="stat-value">${escapeHtml(gpuVendor)}</span><span class="stat-label">Производитель</span></div>
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(gpuModel)}</span><span class="stat-label">Модель</span></div>
</div>
<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 => {
const pcieLink = formatPCIeLink(
gpu.current_link_width || gpu.link_width,
gpu.current_link_speed || gpu.link_speed,
gpu.max_link_width,
gpu.max_link_speed
);
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>${pcieLink}</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) {
const nicCount = config.network_adapters.length;
const totalPorts = config.network_adapters.reduce((sum, n) => sum + (n.port_count || 0), 0);
const nicTypes = [...new Set(config.network_adapters.map(n => n.port_type).filter(t => t))];
const nicModels = [...new Set(config.network_adapters.map(n => n.model).filter(m => m))];
html += `<h3>Сетевые адаптеры</h3>
<div class="section-overview">
<div class="stat-box"><span class="stat-value">${nicCount}</span><span class="stat-label">Адаптеров</span></div>
<div class="stat-box"><span class="stat-value">${totalPorts}</span><span class="stat-label">Портов</span></div>
<div class="stat-box"><span class="stat-value">${nicTypes.join(', ') || '-'}</span><span class="stat-label">Тип портов</span></div>
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(nicModels.join(', ') || '-')}</span><span class="stat-label">Модели</span></div>
</div>
<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 => {
const pcieLink = formatPCIeLink(
p.link_width,
p.link_speed,
p.max_link_width,
p.max_link_speed
);
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>${pcieLink}</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);
}
}
let allFirmware = [];
function renderFirmware(firmware) {
allFirmware = firmware || [];
// Render in Firmware tab
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>';
} else {
firmware.forEach(fw => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${escapeHtml(fw.component)}</td>
<td>${escapeHtml(fw.model)}</td>
<td><code>${escapeHtml(fw.version)}</code></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': 'БП',
'Firmware': 'Прошивка',
'FRU': 'FRU'
};
serials.forEach(item => {
// Skip items without serial number or with N/A
if (!item.serial_number || item.serial_number === 'N/A') 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>${escapeHtml(item.location || '-')}</td>
<td><code>${escapeHtml(item.serial_number)}</code></td>
<td>${escapeHtml(item.manufacturer || '-')}</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);
}
}
// Restart app (reload page)
function restartApp() {
if (confirm('Перезапустить приложение? Все загруженные данные будут потеряны.')) {
fetch('/api/clear', { method: 'DELETE' }).then(() => {
window.location.reload();
});
}
}
// Exit app (shutdown server)
async function exitApp() {
if (confirm('Завершить работу приложения?')) {
try {
await fetch('/api/shutdown', { method: 'POST' });
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;"><div style="text-align:center;"><h1>LOGPile</h1><p>Приложение завершено. Можете закрыть эту вкладку.</p></div></div>';
} catch (err) {
// Server shutdown, connection will fail
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;"><div style="text-align:center;"><h1>LOGPile</h1><p>Приложение завершено. Можете закрыть эту вкладку.</p></div></div>';
}
}
}
// 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;
}
function formatPCIeLink(currentWidth, currentSpeed, maxWidth, maxSpeed) {
// Helper to convert speed to generation
function speedToGen(speed) {
if (!speed) return '';
const gtMatch = speed.match(/(\d+\.?\d*)\s*GT/i);
if (gtMatch) {
const gts = parseFloat(gtMatch[1]);
if (gts >= 32) return 'Gen5';
if (gts >= 16) return 'Gen4';
if (gts >= 8) return 'Gen3';
if (gts >= 5) return 'Gen2';
if (gts >= 2.5) return 'Gen1';
}
return '';
}
// Helper to extract GT/s value for comparison
function extractGTs(speed) {
if (!speed) return 0;
const gtMatch = speed.match(/(\d+\.?\d*)\s*GT/i);
return gtMatch ? parseFloat(gtMatch[1]) : 0;
}
// If no data, return dash
if (!currentWidth && !currentSpeed) return '-';
const curGen = speedToGen(currentSpeed);
const maxGen = speedToGen(maxSpeed);
// Check if current is lower than max
const widthDegraded = maxWidth && currentWidth && currentWidth < maxWidth;
const speedDegraded = maxSpeed && currentSpeed && extractGTs(currentSpeed) < extractGTs(maxSpeed);
// Build current link string
const curWidthStr = currentWidth ? `x${currentWidth}` : '';
const curLinkStr = curGen ? `${curWidthStr} ${curGen}` : `${curWidthStr} ${currentSpeed || ''}`;
// Build max link string (if available)
let maxLinkStr = '';
if (maxWidth || maxSpeed) {
const maxWidthStr = maxWidth ? `x${maxWidth}` : '';
maxLinkStr = maxGen ? `${maxWidthStr} ${maxGen}` : `${maxWidthStr} ${maxSpeed || ''}`;
}
// Apply degraded class if needed
const degradedClass = (widthDegraded || speedDegraded) ? ' class="pcie-degraded"' : '';
// Format output: show "current" or "current / max" if max differs
if (maxLinkStr && (widthDegraded || speedDegraded)) {
return `<span${degradedClass}>${curLinkStr}</span> <span class="pcie-max">/ ${maxLinkStr}</span>`;
} else if (maxLinkStr && maxLinkStr !== curLinkStr) {
return `${curLinkStr} <span class="pcie-max">/ ${maxLinkStr}</span>`;
} else {
return curLinkStr;
}
}