// LOGPile Frontend Application document.addEventListener('DOMContentLoaded', () => { initSourceType(); initApiSource(); initUpload(); initConvertMode(); initTabs(); initFilters(); loadParsersInfo(); }); let sourceType = 'archive'; let convertFiles = []; let isConvertRunning = false; 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) { if (nextType === 'api') { sourceType = 'api'; } else if (nextType === 'convert') { sourceType = 'convert'; } else { sourceType = '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'); const convertSourceContent = document.getElementById('convert-source-content'); archiveContent.classList.toggle('hidden', sourceType !== 'archive'); apiSourceContent.classList.toggle('hidden', sourceType !== 'api'); if (convertSourceContent) { convertSourceContent.classList.toggle('hidden', sourceType !== 'convert'); } } 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 = `Исправьте ошибки в форме:`; } 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') { 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 etaValue = document.getElementById('job-eta-value'); const progressBar = document.getElementById('job-progress-bar'); const logsList = document.getElementById('job-logs-list'); const cancelButton = document.getElementById('cancel-job-btn'); if (!jobStatusBlock || !jobIdValue || !statusValue || !progressValue || !etaValue || !progressBar || !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 activity = isTerminal ? terminalMessage : latestCollectionActivityMessage(); const eta = isTerminal ? '-' : latestCollectionETA(); const progressPercent = Math.max(0, Math.min(100, Number(collectionJob.progress) || 0)); progressValue.textContent = activity; etaValue.textContent = eta; progressBar.style.width = `${progressPercent}%`; progressBar.textContent = `${progressPercent}%`; logsList.innerHTML = [...collectionJob.logs].reverse().map((log) => ( `
  • ${escapeHtml(log.time)}${escapeHtml(log.message)}
  • ` )).join(''); cancelButton.disabled = isTerminal; setApiFormBlocked(!isTerminal); } function latestCollectionActivityMessage() { if (!collectionJob || !Array.isArray(collectionJob.logs) || collectionJob.logs.length === 0) { return 'Сбор данных...'; } const last = String(collectionJob.logs[collectionJob.logs.length - 1].message || '').trim(); if (!last) { return 'Сбор данных...'; } // Job logs already contain server timestamp prefix. Show concise step text in progress label. const cleaned = last.replace(/^\d{4}-\d{2}-\d{2}T[^\s]+\s+/, '').trim(); if (!cleaned) { return 'Сбор данных...'; } return cleaned.replace(/\s*[,(]?\s*ETA[^,;)]*/i, '').trim() || 'Сбор данных...'; } function latestCollectionETA() { if (!collectionJob || !Array.isArray(collectionJob.logs) || collectionJob.logs.length === 0) { return '-'; } const last = String(collectionJob.logs[collectionJob.logs.length - 1].message || '').trim(); const cleaned = last.replace(/^\d{4}-\d{2}-\d{2}T[^\s]+\s+/, '').trim(); if (!cleaned) { return '-'; } const match = cleaned.match(/ETA[^,;)]*/i); if (!match) { return '-'; } const eta = match[0].replace(/^ETA\s*[:=~≈-]?\s*/i, '').trim(); return eta || '-'; } 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 = '

    Поддерживаемые платформы:

    '; data.parsers.forEach(p => { html += `
    ${escapeHtml(p.name)} v${escapeHtml(p.version)}
    `; }); html += '
    '; 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 = `${escapeHtml(result.vendor)}
    ` + `${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'; } } function initConvertMode() { const folderInput = document.getElementById('convert-folder-input'); const runButton = document.getElementById('convert-run-btn'); if (!folderInput || !runButton) { return; } folderInput.addEventListener('change', () => { convertFiles = Array.from(folderInput.files || []).filter(file => file && file.name); renderConvertSummary(); }); runButton.addEventListener('click', async () => { await runConvertBatch(); }); renderConvertSummary(); } function renderConvertSummary() { const summary = document.getElementById('convert-folder-summary'); if (!summary) { return; } if (convertFiles.length === 0) { summary.textContent = 'Выберите папку с файлами, включая вложенные каталоги.'; summary.className = 'api-connect-status'; return; } const supportedFiles = convertFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name)); const skippedCount = convertFiles.length - supportedFiles.length; const previewCount = 5; const previewFiles = supportedFiles.slice(0, previewCount).map(file => escapeHtml(file.webkitRelativePath || file.name)); const remaining = supportedFiles.length - previewFiles.length; const previewText = previewFiles.length > 0 ? `Примеры: ${previewFiles.join(', ')}` : ''; const skippedText = skippedCount > 0 ? ` Пропущено неподдерживаемых: ${skippedCount}.` : ''; summary.innerHTML = `${supportedFiles.length} файлов готовы к конвертации.${previewText ? ` ${previewText}` : ''}${remaining > 0 ? ` и ещё ${remaining}` : ''}.${skippedText}`; summary.className = 'api-connect-status'; } async function runConvertBatch() { const runButton = document.getElementById('convert-run-btn'); if (!runButton || isConvertRunning) { return; } if (convertFiles.length === 0) { renderConvertStatus('Нет файлов для конвертации', 'error'); return; } const supportedFiles = convertFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name)); if (supportedFiles.length === 0) { renderConvertStatus('В выбранной папке нет файлов поддерживаемого типа', 'error'); return; } isConvertRunning = true; runButton.disabled = true; renderConvertProgress(0, 'Подготовка загрузки...'); renderConvertStatus('Выполняю пакетную конвертацию...', 'info'); const formData = new FormData(); supportedFiles.forEach(file => { const relativePath = file.webkitRelativePath || file.name || 'file'; formData.append('files[]', file, relativePath); }); try { const startResponse = await uploadConvertBatch(formData, (percent) => { const uploadPercent = Math.round(percent * 0.3); renderConvertProgress(uploadPercent, `Загрузка файлов: ${percent}%`); }); if (!startResponse.ok) { const errorPayload = parseConvertErrorPayload(startResponse.bodyText); hideConvertProgress(); renderConvertStatus(errorPayload.error || 'Пакетная конвертация завершилась с ошибкой', 'error'); return; } if (!startResponse.jobId) { hideConvertProgress(); renderConvertStatus('Сервер не вернул идентификатор задачи', 'error'); return; } await waitForConvertJob(startResponse.jobId, (statusPayload) => { const serverProgress = Number(statusPayload.progress || 0); const combined = 30 + Math.round(Math.max(0, Math.min(100, serverProgress)) * 0.7); renderConvertProgress(combined, `Конвертация: ${serverProgress}%`); }); renderConvertProgress(100, 'Подготовка выгрузки...'); const downloadResponse = await downloadConvertArchive(startResponse.jobId); if (!downloadResponse.ok) { const errorPayload = parseConvertErrorPayload(downloadResponse.bodyText); hideConvertProgress(); renderConvertStatus(errorPayload.error || 'Не удалось скачать результат', 'error'); return; } const blob = downloadResponse.blob; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); downloadBlob(blob, `logpile-convert-${timestamp}.zip`); const summary = downloadResponse.summaryHeader || 'Конвертация завершена'; hideConvertProgress(); renderConvertStatus(summary, 'success'); } catch (err) { hideConvertProgress(); renderConvertStatus('Ошибка соединения при конвертации', 'error'); } finally { isConvertRunning = false; runButton.disabled = false; } } function uploadConvertBatch(formData, onUploadPercent) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/convert'); xhr.responseType = 'text'; xhr.upload.addEventListener('progress', (event) => { if (!event.lengthComputable) { return; } const percent = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100))); onUploadPercent(percent); }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { let body = {}; try { body = JSON.parse(xhr.responseText || '{}'); } catch (err) { body = {}; } resolve({ ok: true, status: xhr.status, jobId: body.job_id || '' }); return; } resolve({ ok: false, status: xhr.status, bodyText: xhr.responseText || '' }); }); xhr.addEventListener('error', () => { reject(new Error('network')); }); xhr.send(formData); }); } async function waitForConvertJob(jobId, onProgress) { while (true) { const response = await fetch(`/api/convert/${encodeURIComponent(jobId)}`); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.error || 'Не удалось получить статус конвертации'); } if (onProgress) { onProgress(payload); } const status = String(payload.status || '').toLowerCase(); if (status === 'success') { return payload; } if (status === 'failed' || status === 'canceled') { throw new Error(payload.error || 'Конвертация завершилась ошибкой'); } await delay(900); } } async function downloadConvertArchive(jobId) { const response = await fetch(`/api/convert/${encodeURIComponent(jobId)}/download`); if (!response.ok) { return { ok: false, bodyText: await response.text().catch(() => '') }; } return { ok: true, blob: await response.blob(), summaryHeader: response.headers.get('X-Convert-Summary') || '' }; } function delay(ms) { return new Promise((resolve) => { window.setTimeout(resolve, ms); }); } function parseConvertErrorPayload(bodyText) { if (!bodyText) { return {}; } try { return JSON.parse(bodyText); } catch (err) { return {}; } } function isSupportedConvertFileName(filename) { const name = String(filename || '').trim().toLowerCase(); if (!name) { return false; } return ( name.endsWith('.zip') || name.endsWith('.tar') || name.endsWith('.tar.gz') || name.endsWith('.tgz') || name.endsWith('.json') || name.endsWith('.txt') || name.endsWith('.log') ); } function renderConvertStatus(message, status) { const statusNode = document.getElementById('convert-status'); if (!statusNode) { return; } statusNode.textContent = message || ''; statusNode.className = 'api-connect-status'; if (status === 'success') { statusNode.classList.add('success'); } else if (status === 'error') { statusNode.classList.add('error'); } else if (status === 'info') { statusNode.classList.add('info'); } } function renderConvertProgress(percent, label) { const wrap = document.getElementById('convert-progress'); const bar = document.getElementById('convert-progress-bar'); const value = document.getElementById('convert-progress-value'); const text = document.getElementById('convert-progress-label'); if (!wrap || !bar || !value || !text) { return; } const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); wrap.classList.remove('hidden'); bar.style.width = `${safePercent}%`; value.textContent = `${safePercent}%`; text.textContent = label || 'Выполняется...'; } function hideConvertProgress() { const wrap = document.getElementById('convert-progress'); if (!wrap) { return; } wrap.classList.add('hidden'); } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.style.display = 'none'; link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.setTimeout(() => { URL.revokeObjectURL(url); }, 3000); } // 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 allParseErrors = []; 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(), loadParseErrors() ]); } 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 = '

    Нет данных о конфигурации

    '; return; } const config = data.hardware || data; const spec = data.specification; const redfishFetchErrors = Array.isArray(data.redfish_fetch_errors) ? data.redfish_fetch_errors : []; const devices = Array.isArray(config.devices) ? config.devices : []; const volumes = Array.isArray(config.volumes) ? config.volumes : []; const cpus = devices.filter(d => d.kind === 'cpu'); const memory = devices.filter(d => d.kind === 'memory'); const powerSupplies = devices.filter(d => d.kind === 'psu'); const storage = devices.filter(d => d.kind === 'storage'); const gpus = devices.filter(d => d.kind === 'gpu'); const networkAdapters = devices.filter(d => d.kind === 'network'); const inventoryRows = devices.filter(d => ['pcie', 'storage', 'gpu', 'network'].includes(d.kind)); const pcieBalance = calculateCPUToPCIeBalance(inventoryRows, cpus); const pcieByCPU = new Map(); pcieBalance.perCPU.forEach(item => { const idx = extractCPUIndex(item.label); if (idx !== null) pcieByCPU.set(idx, item.lanes); }); const memoryByCPU = calculateMemoryModulesByCPU(memory); let html = ''; // Server info header if (config.board) { html += `
    Модель сервера: ${escapeHtml(config.board.product_name || '-')}
    Серийный номер: ${escapeHtml(config.board.serial_number || '-')}
    `; } // Configuration sub-tabs html += `
    `; // Specification tab html += '
    '; const partialInventory = detectPartialRedfishInventory({ cpus, memory, redfishFetchErrors }); if (partialInventory) { html += `

    Частичный инвентарь

    ${escapeHtml(partialInventory)}

    `; } if (spec && spec.length > 0) { html += '

    Спецификация сервера

    '; } else { html += '

    Нет данных о спецификации

    '; } html += '
    '; // CPU tab html += '
    '; if (cpus.length > 0) { const cpuCount = cpus.length; const cpuModel = cpus[0].model || '-'; const totalCores = cpus.reduce((sum, c) => sum + (c.cores || 0), 0); const totalThreads = cpus.reduce((sum, c) => sum + (c.threads || 0), 0); const balanceClass = pcieBalance.severity === 'critical' ? 'pcie-balance-critical' : (pcieBalance.severity === 'warning' ? 'pcie-balance-warning' : 'pcie-balance-ok'); const balanceLabel = pcieBalance.severity === 'critical' ? 'Перевес высокий' : (pcieBalance.severity === 'warning' ? 'Есть перевес' : 'Распределено ровно'); html += `

    Процессоры

    ${cpuCount}Процессоров
    ${totalCores}Ядер
    ${totalThreads}Потоков
    ${pcieBalance.totalLanes}Занято PCIe линий
    ${balanceLabel}Баланс PCIe
    ${escapeHtml(cpuModel)}Модель
    `; pcieBalance.perCPU.forEach(cpu => { html += `
    ${escapeHtml(cpu.label)}
    ${cpu.lanes}
    `; }); html += `
    `; cpus.forEach(cpu => { const socket = cpu.slot || '-'; const cpuIdx = extractCPUIndex(socket); const pcieUsed = cpuIdx !== null ? (pcieByCPU.get(cpuIdx) || 0) : '-'; const memoryModules = cpuIdx !== null ? (memoryByCPU.get(cpuIdx) || 0) : '-'; const tdp = (cpu.details && cpu.details.tdp_w) || '-'; const l3 = (cpu.details && cpu.details.l3_cache_kb) ? Math.round(cpu.details.l3_cache_kb / 1024) : '-'; const ppin = (cpu.details && cpu.details.ppin) || '-'; html += ``; }); html += '
    SocketМодельЯдраПотокиЧастотаMax TurboTDPL3 CachePCIe линии (занято)Модулей памятиPPIN
    ${escapeHtml(socket)} ${escapeHtml(cpu.model || '-')} ${cpu.cores || '-'} ${cpu.threads || '-'} ${cpu.frequency_mhz ? cpu.frequency_mhz + ' MHz' : '-'} ${cpu.max_frequency_mhz ? cpu.max_frequency_mhz + ' MHz' : '-'} ${tdp !== '-' ? tdp + 'W' : '-'} ${l3 !== '-' ? l3 + ' MB' : '-'} ${pcieUsed} ${memoryModules} ${escapeHtml(ppin)}
    '; } else { html += '

    Нет данных о процессорах

    '; } html += '
    '; // Memory tab html += '
    '; if (memory.length > 0) { const totalGB = memory.reduce((sum, m) => sum + (m.size_mb || 0), 0) / 1024; const presentCount = memory.filter(m => m.present !== false).length; const workingCount = memory.filter(m => (m.size_mb || 0) > 0).length; html += `

    Модули памяти

    ${totalGB} GBВсего
    ${presentCount}Установлено
    ${workingCount}Активно
    `; memory.forEach(mem => { const present = mem.present !== false ? '✓' : '-'; const presentClass = mem.present !== false ? 'present-yes' : 'present-no'; const sizeGB = (mem.size_mb || 0) / 1024; const statusClass = (mem.status === 'OK' || !mem.status) ? '' : 'status-warning'; const rowClass = sizeGB === 0 ? 'row-warning' : ''; html += ``; }); html += '
    LocationНаличиеРазмерТипMax частотаТекущая частотаПроизводительАртикулСерийный номерСтатус
    ${escapeHtml(mem.location || mem.slot)} ${present} ${sizeGB} GB ${escapeHtml(mem.type || '-')} ${(mem.details && mem.details.max_speed_mhz) || '-'} MHz ${(mem.details && mem.details.current_speed_mhz) || mem.speed_mhz || '-'} MHz ${escapeHtml(mem.manufacturer || '-')} ${escapeHtml(mem.part_number || '-')} ${escapeHtml(mem.serial_number || '-')} ${escapeHtml(mem.status || 'OK')}
    '; } else { html += '

    Нет данных о памяти

    '; } html += '
    '; // Power tab html += '
    '; if (powerSupplies.length > 0) { const psuTotal = powerSupplies.length; const psuPresent = powerSupplies.filter(p => p.present !== false).length; const psuOK = powerSupplies.filter(p => p.status === 'OK').length; const psuModel = powerSupplies[0].model || '-'; const psuWattage = powerSupplies[0].wattage_w || 0; const psuCurrentPowerW = powerSupplies.reduce((sum, psu) => { if (Number.isFinite(psu.output_power_w) && psu.output_power_w > 0) { return sum + psu.output_power_w; } if (Number.isFinite(psu.input_power_w) && psu.input_power_w > 0) { return sum + psu.input_power_w; } return sum; }, 0); const psuCurrentPowerLabel = psuCurrentPowerW > 0 ? `${psuCurrentPowerW}W` : '-'; html += `

    Блоки питания

    ${psuTotal}Всего
    ${psuPresent}Подключено
    ${psuOK}Работает
    ${psuWattage}WМощность
    ${psuCurrentPowerLabel}Текущая суммарная
    ${escapeHtml(psuModel)}Модель
    `; powerSupplies.forEach(psu => { const statusClass = psu.status === 'OK' ? '' : 'status-warning'; html += ``; }); html += '
    СлотПроизводительМодельМощностьВходВыходНапряжениеТемператураСтатус
    ${escapeHtml(psu.slot)} ${escapeHtml(psu.manufacturer || psu.vendor || '-')} ${escapeHtml(psu.model || '-')} ${psu.wattage_w || '-'}W ${psu.input_power_w || '-'}W ${psu.output_power_w || '-'}W ${psu.input_voltage ? psu.input_voltage.toFixed(0) : '-'}V ${psu.temperature_c || '-'}°C ${escapeHtml(psu.status || '-')}
    '; } else { html += '

    Нет данных о блоках питания

    '; } html += '
    '; // Storage tab html += '
    '; if (storage.length > 0 || volumes.length > 0) { const storTotal = storage.length; const storHDD = storage.filter(s => s.type === 'HDD').length; const storSSD = storage.filter(s => s.type === 'SSD').length; const storNVMe = storage.filter(s => s.type === 'NVMe').length; const totalTB = (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 += `

    Накопители

    ${storTotal}Всего слотов
    ${storage.filter(s => s.present).length}Установлено
    ${totalTB > 0 ? totalTB + ' TB' : '-'}Объём
    ${volumes.length}Логических томов
    ${typesSummary.join(', ') || '-'}По типам
    `; storage.forEach(s => { const presentIcon = s.present ? '' : ''; const presentText = s.present ? 'Present' : 'Empty'; html += ``; }); html += '
    NO.СтатусРасположениеBackplane IDТипМодельРазмерСерийный номер
    ${escapeHtml(s.slot || '-')} ${presentIcon} ${presentText} ${escapeHtml(s.location || '-')} ${s.details && s.details.backplane_id !== undefined ? s.details.backplane_id : '-'} ${escapeHtml(s.type || '-')} ${escapeHtml(s.model || '-')} ${s.size_gb > 0 ? s.size_gb + ' GB' : '-'} ${s.serial_number ? '' + escapeHtml(s.serial_number) + '' : '-'}
    '; if (volumes.length > 0) { html += `

    Логические тома (RAID/VROC)

    `; volumes.forEach(v => { html += ``; }); html += '
    IDИмяКонтроллерRAIDРазмерСтатус
    ${escapeHtml(v.id || '-')} ${escapeHtml(v.name || '-')} ${escapeHtml(v.controller || '-')} ${escapeHtml(v.raid_level || '-')} ${v.size_gb > 0 ? `${v.size_gb} GB` : '-'} ${escapeHtml(v.status || '-')}
    '; } } else { html += '

    Нет данных о накопителях

    '; } html += '
    '; // GPU tab html += '
    '; const gpuRows = gpus; if (gpuRows.length > 0) { const gpuCount = gpuRows.length; const gpuModel = gpuRows[0].model || '-'; const gpuVendor = gpuRows[0].manufacturer || '-'; html += `

    Графические процессоры

    ${gpuCount}Всего GPU
    ${escapeHtml(gpuVendor)}Производитель
    ${escapeHtml(gpuModel)}Модель
    `; gpuRows.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 += ``; }); html += '
    СлотМодельПроизводительBDFPCIeСерийный номер
    ${escapeHtml(gpu.slot || '-')} ${escapeHtml(gpu.model || '-')} ${escapeHtml(gpu.manufacturer || '-')} ${escapeHtml(gpu.bdf || '-')} ${pcieLink} ${escapeHtml(gpu.serial_number || '-')}
    '; } else { html += '

    Нет GPU

    '; } html += '
    '; // Network tab html += '
    '; const networkRows = networkAdapters; const normalizeNetworkPortCount = (value) => { const num = Number(value); if (!Number.isFinite(num) || num <= 0 || num > 256) { return null; } return Math.trunc(num); }; if (networkRows.length > 0) { const nicCount = networkRows.length; const totalPorts = networkRows.reduce((sum, n) => sum + (normalizeNetworkPortCount(n.port_count) || 0), 0); const nicTypes = [...new Set(networkRows.map(n => n.port_type).filter(t => t))]; const nicModels = [...new Set(networkRows.map(n => n.model).filter(m => m))]; html += `

    Сетевые адаптеры

    ${nicCount}Адаптеров
    ${totalPorts}Портов
    ${nicTypes.join(', ') || '-'}Тип портов
    ${escapeHtml(nicModels.join(', ') || '-')}Модели
    `; networkRows.forEach(nic => { const macs = nic.mac_addresses ? nic.mac_addresses.join(', ') : '-'; const statusClass = nic.status === 'OK' ? '' : 'status-warning'; const displayPortCount = normalizeNetworkPortCount(nic.port_count); html += ``; }); html += '
    СлотМодельПроизводительПортыТипMAC адресаСтатус
    ${escapeHtml(nic.location || nic.slot || '-')} ${escapeHtml(nic.model || '-')} ${escapeHtml(nic.manufacturer || nic.vendor || '-')} ${displayPortCount ?? '-'} ${escapeHtml(nic.port_type || '-')} ${escapeHtml(macs)} ${escapeHtml(nic.status || '-')}
    '; } else { html += '

    Нет данных о сетевых адаптерах

    '; } html += '
    '; // PCIe Device Inventory tab html += '
    '; if (inventoryRows.length > 0) { html += '

    PCIe устройства

    '; const groups = new Map(); inventoryRows.forEach(p => { const idx = extractCPUIndex(p.slot); const key = idx === null ? 'other' : `cpu${idx}`; if (!groups.has(key)) { groups.set(key, { idx, title: idx === null ? 'Без привязки к CPU' : `CPU${idx}`, lanes: 0, rows: [] }); } const lanes = Number(p.link_width) > 0 ? Number(p.link_width) : (Number(p.max_link_width) > 0 ? Number(p.max_link_width) : 0); const group = groups.get(key); group.lanes += lanes; group.rows.push(p); }); const sortedGroups = [...groups.values()].sort((a, b) => { if (a.idx === null) return 1; if (b.idx === null) return -1; return a.idx - b.idx; }); sortedGroups.forEach(group => { html += `

    ${escapeHtml(group.title)} · занято линий: ${group.lanes}

    `; html += ''; group.rows.forEach(p => { const pcieLink = formatPCIeLink( p.link_width, p.link_speed, p.max_link_width, p.max_link_speed ); const firmware = p.firmware || findPCIeFirmwareVersion(config.firmware, p); html += ``; }); html += '
    СлотBDFМодельПроизводительVendor:Device IDPCIe LinkСерийный номерПрошивка
    ${escapeHtml(p.slot || '-')} ${escapeHtml(p.bdf || '-')} ${escapeHtml(p.model || p.part_number || p.device_class || '-')} ${escapeHtml(p.manufacturer || '-')} ${p.vendor_id ? p.vendor_id.toString(16) : '-'}:${p.device_id ? p.device_id.toString(16) : '-'} ${pcieLink} ${escapeHtml(p.serial_number || '-')} ${escapeHtml(firmware || '-')}
    '; }); } else { html += '

    Нет данных о PCIe устройствах

    '; } html += '
    '; 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 = 'Нет данных о прошивках'; } else { firmware.forEach(fw => { const row = document.createElement('tr'); row.innerHTML = ` ${escapeHtml(fw.component)} ${escapeHtml(fw.model)} ${escapeHtml(fw.version)} `; 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 = '

    Нет данных о сенсорах

    '; 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 += `

    ${typeNames[type] || type}

    `; items.forEach(s => { let valueStr = ''; let statusClass = s.status === 'ok' ? 'ok' : (s.status === 'ns' ? 'ns' : 'warn'); const sensorName = String(s.name || '').toLowerCase(); const isPSUVoltage = type === 'voltage' && sensorName.includes('psu') && sensorName.includes('voltage'); if (Number.isFinite(s.value)) { valueStr = `${s.value} ${s.unit}`; } else if (s.raw_value) { valueStr = s.raw_value; } else { valueStr = s.status; } // Server computes PSU voltage range status; UI only reflects it. let extraClass = ''; if (isPSUVoltage && s.status === 'warn') { extraClass = ' voltage-out-of-range'; } html += `
    ${escapeHtml(s.name)} ${escapeHtml(valueStr)}
    `; }); html += '
    '; } 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 = 'Нет серийных номеров'; return; } const categoryNames = { 'Board': 'Мат. плата', 'CPU': 'Процессор', 'Memory': 'Память', 'Storage': 'Накопитель', 'GPU': 'Видеокарта', '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 = ` ${categoryNames[item.category] || item.category} ${escapeHtml(item.component)} ${escapeHtml(item.location || '-')} ${escapeHtml(item.serial_number)} ${escapeHtml(item.manufacturer || '-')} `; 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); } } async function loadParseErrors() { try { const response = await fetch('/api/parse-errors'); const payload = await response.json(); allParseErrors = Array.isArray(payload && payload.items) ? payload.items : []; renderParseErrors(allParseErrors); } catch (err) { console.error('Failed to load parse errors:', err); allParseErrors = []; renderParseErrors([]); } } function renderParseErrors(items) { const tbody = document.querySelector('#parse-errors-table tbody'); if (!tbody) return; tbody.innerHTML = ''; if (!items || items.length === 0) { tbody.innerHTML = 'Ошибок разбора не обнаружено'; return; } items.forEach(item => { const row = document.createElement('tr'); const severity = (item.severity || 'info').toLowerCase(); const source = item.source || '-'; const category = item.category || '-'; const path = item.path || '-'; const message = item.message || item.detail || '-'; row.innerHTML = ` ${escapeHtml(source)} ${escapeHtml(category)} ${escapeHtml(severity)} ${escapeHtml(path)} ${escapeHtml(message)} `; tbody.appendChild(row); }); } function renderEvents(events) { const tbody = document.querySelector('#events-table tbody'); tbody.innerHTML = ''; if (!events || events.length === 0) { tbody.innerHTML = 'Нет событий'; return; } events.forEach(event => { const row = document.createElement('tr'); row.innerHTML = ` ${formatDate(event.timestamp)} ${escapeHtml(event.source)} ${escapeHtml(event.description)} ${event.severity} `; 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 = []; allParseErrors = []; } 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 = '

    LOGPile

    Приложение завершено. Можете закрыть эту вкладку.

    '; } catch (err) { // Server shutdown, connection will fail document.body.innerHTML = '

    LOGPile

    Приложение завершено. Можете закрыть эту вкладку.

    '; } } } // 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 detectPartialRedfishInventory({ cpus, memory, redfishFetchErrors }) { const errors = Array.isArray(redfishFetchErrors) ? redfishFetchErrors : []; const paths = errors.map(item => String(item && typeof item === 'object' ? (item.path || '') : '')).filter(Boolean); const cpuMissing = (!Array.isArray(cpus) || cpus.length === 0) && paths.some(p => /\/Systems\/[^/]+\/Processors(\/)?$/i.test(p)); const memMissing = (!Array.isArray(memory) || memory.length === 0) && paths.some(p => /\/Systems\/[^/]+\/Memory(\/)?$/i.test(p)); if (!cpuMissing && !memMissing) return ''; if (cpuMissing && memMissing) return 'Не удалось восстановить CPU и Memory: Redfish endpoint\'ы /Processors и /Memory были недоступны во время сбора.'; if (cpuMissing) return 'CPU-инвентарь неполный: Redfish endpoint /Processors был недоступен во время сбора.'; return 'Memory-инвентарь неполный: Redfish endpoint /Memory был недоступен во время сбора.'; } function calculateCPUToPCIeBalance(inventoryRows, cpus) { const laneByCPU = new Map(); const cpuIndexes = new Set(); (cpus || []).forEach(cpu => { const idx = extractCPUIndex(cpu.slot); if (idx !== null) { cpuIndexes.add(idx); laneByCPU.set(idx, 0); } }); (inventoryRows || []).forEach(dev => { const idx = extractCPUIndex(dev.slot); if (idx === null) return; const lanes = Number(dev.link_width) > 0 ? Number(dev.link_width) : (Number(dev.max_link_width) > 0 ? Number(dev.max_link_width) : (dev.bdf ? 1 : 0)); if (lanes <= 0) return; if (!laneByCPU.has(idx)) laneByCPU.set(idx, 0); laneByCPU.set(idx, laneByCPU.get(idx) + lanes); cpuIndexes.add(idx); }); const indexes = [...cpuIndexes].sort((a, b) => a - b); const values = indexes.map(i => laneByCPU.get(i) || 0); const totalLanes = values.reduce((a, b) => a + b, 0); const maxLanes = values.length ? Math.max(...values) : 0; const minLanes = values.length ? Math.min(...values) : 0; const diffRatio = totalLanes > 0 ? (maxLanes - minLanes) / totalLanes : 0; let severity = 'ok'; if (values.length > 1) { if (diffRatio >= 0.35) severity = 'critical'; else if (diffRatio >= 0.2) severity = 'warning'; } const denominator = maxLanes > 0 ? maxLanes : 1; const perCPU = indexes.map(i => { const lanes = laneByCPU.get(i) || 0; return { label: `CPU${i}`, lanes, percent: Math.round((lanes / denominator) * 100) }; }); if (perCPU.length === 0) { perCPU.push({ label: 'CPU?', lanes: 0, percent: 0 }); } return { totalLanes, severity, perCPU }; } function extractCPUIndex(slot) { const s = String(slot || '').trim(); if (!s) return null; const m = s.match(/cpu\s*([0-9]+)/i); if (!m) return null; const idx = Number(m[1]); return Number.isFinite(idx) ? idx : null; } function calculateMemoryModulesByCPU(memoryRows) { const out = new Map(); (memoryRows || []).forEach(mem => { if (mem.present === false || (mem.size_mb || 0) <= 0) return; const idx = extractCPUIndex(mem.location || mem.slot); if (idx === null) return; out.set(idx, (out.get(idx) || 0) + 1); }); return out; } function findPCIeFirmwareVersion(firmwareEntries, pcieDevice) { if (!Array.isArray(firmwareEntries) || !pcieDevice) return ''; const slot = (pcieDevice.slot || '').trim().toLowerCase(); const model = (pcieDevice.part_number || '').trim().toLowerCase(); if (!slot && !model) return ''; const slotPatterns = slot ? [ new RegExp(`^psu\\s*${escapeRegExp(slot)}\\b`, 'i'), new RegExp(`^nic\\s+${escapeRegExp(slot)}\\b`, 'i'), new RegExp(`^gpu\\s+${escapeRegExp(slot)}\\b`, 'i'), new RegExp(`^nvswitch\\s+${escapeRegExp(slot)}\\b`, 'i') ] : []; for (const fw of firmwareEntries) { const name = (fw.device_name || '').trim().toLowerCase(); const version = (fw.version || '').trim(); if (!name || !version) continue; if (slot && slotPatterns.some(re => re.test(name))) return version; if (model && name.includes(model)) return version; } return ''; } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } 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 `${curLinkStr} / ${maxLinkStr}`; } else if (maxLinkStr && maxLinkStr !== curLinkStr) { return `${curLinkStr} / ${maxLinkStr}`; } else { return curLinkStr; } }