// LOGPile Frontend Application
document.addEventListener('DOMContentLoaded', () => {
initSourceType();
initApiSource();
initUpload();
initConvertMode();
initAuditViewer();
loadParsersInfo();
loadSupportedFileTypes();
});
let sourceType = 'archive';
let convertFiles = [];
let isConvertRunning = false;
let convertDuplicates = [];
const CONVERT_MAX_FILES_PER_BATCH = 1000;
let supportedUploadExtensions = null;
let supportedConvertExtensions = null;
let apiConnectPayload = null;
let collectionJob = null;
let collectionJobPollTimer = null;
let collectionJobLogCounter = 0;
let apiPortTouchedByUser = false;
let isAutoUpdatingApiPort = false;
let apiProbeResult = null;
let apiPowerDecisionTimer = null;
function initAuditViewer() {
const frame = document.getElementById('audit-viewer-frame');
if (!frame) {
return;
}
frame.addEventListener('load', () => {
resizeAuditViewerFrame();
try {
const win = frame.contentWindow;
if (win) {
win.setTimeout(resizeAuditViewerFrame, 50);
win.setTimeout(resizeAuditViewerFrame, 250);
}
} catch (err) {
console.error('Failed to schedule viewer resize:', err);
}
});
window.addEventListener('resize', () => {
resizeAuditViewerFrame();
});
}
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 connectButton = document.getElementById('api-connect-btn');
const collectButton = document.getElementById('api-collect-btn');
const powerOffCheckbox = document.getElementById('api-power-off');
const fieldNames = ['host', 'port', 'username', 'password'];
apiForm.addEventListener('submit', (event) => {
event.preventDefault();
if (apiProbeResult && apiProbeResult.reachable) {
startCollectionWithOptions();
} else {
startApiProbe();
}
});
if (cancelJobButton) {
cancelJobButton.addEventListener('click', () => {
cancelCollectionJob();
});
}
if (connectButton) {
connectButton.addEventListener('click', () => {
startApiProbe();
});
}
if (collectButton) {
collectButton.addEventListener('click', () => {
startCollectionWithOptions();
});
}
if (powerOffCheckbox) {
powerOffCheckbox.addEventListener('change', () => {
if (!powerOffCheckbox.checked) {
return;
}
// If host was already on when probed, warn before enabling shutdown
if (apiProbeResult && apiProbeResult.host_powered_on) {
showConfirmModal(
'Хост был включён до начала сбора. Вы уверены, что хотите выключить его после завершения сбора?',
() => { /* confirmed — leave checked */ },
() => { powerOffCheckbox.checked = false; }
);
}
});
}
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();
resetApiProbeState();
if (collectionJob && isCollectionJobTerminal(collectionJob.status)) {
resetCollectionJobState();
}
});
});
applyRedfishDefaultPort();
renderCollectionJob();
}
function showConfirmModal(message, onConfirm, onCancel) {
const backdrop = document.createElement('div');
backdrop.className = 'api-confirm-modal-backdrop';
backdrop.innerHTML = `
${escapeHtml(message)}
`;
document.body.appendChild(backdrop);
const close = () => document.body.removeChild(backdrop);
backdrop.querySelector('.btn-cancel').addEventListener('click', () => {
close();
if (onCancel) onCancel();
});
backdrop.querySelector('.btn-confirm').addEventListener('click', () => {
close();
if (onConfirm) onConfirm();
});
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) {
close();
if (onCancel) onCancel();
}
});
}
function startApiProbe() {
const { isValid, payload, errors } = validateCollectForm();
renderFormErrors(errors);
if (!isValid) {
renderApiConnectStatus(false);
resetApiProbeState();
return;
}
apiConnectPayload = payload;
resetApiProbeState();
setApiFormBlocked(true);
renderApiConnectStatus(true);
fetch('/api/collect/probe', {
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 || 'Проверка подключения не удалась');
}
apiProbeResult = body;
renderApiProbeState();
})
.catch((err) => {
resetApiProbeState();
renderApiConnectStatus(false);
const status = document.getElementById('api-connect-status');
if (status) {
status.textContent = err.message || 'Проверка подключения не удалась';
status.className = 'api-connect-status error';
}
})
.finally(() => {
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
setApiFormBlocked(false);
}
});
}
function startCollectionWithOptions() {
const { isValid, payload, errors } = validateCollectForm();
renderFormErrors(errors);
if (!isValid) {
renderApiConnectStatus(false);
return;
}
if (!apiProbeResult || !apiProbeResult.reachable) {
const status = document.getElementById('api-connect-status');
if (status) {
status.textContent = 'Сначала выполните проверку подключения.';
status.className = 'api-connect-status error';
}
return;
}
const powerOnCheckbox = document.getElementById('api-power-on');
const powerOffCheckbox = document.getElementById('api-power-off');
const debugPayloads = document.getElementById('api-debug-payloads');
payload.power_on_if_host_off = powerOnCheckbox ? powerOnCheckbox.checked : false;
payload.stop_host_after_collect = powerOffCheckbox ? powerOffCheckbox.checked : false;
payload.debug_payloads = debugPayloads ? debugPayloads.checked : false;
startCollectionJob(payload);
}
function renderApiProbeState() {
const connectButton = document.getElementById('api-connect-btn');
const probeOptions = document.getElementById('api-probe-options');
const status = document.getElementById('api-connect-status');
const powerOnCheckbox = document.getElementById('api-power-on');
const powerOffCheckbox = document.getElementById('api-power-off');
if (!connectButton || !probeOptions || !status) {
return;
}
if (!apiProbeResult || !apiProbeResult.reachable) {
status.textContent = 'Проверка подключения не пройдена.';
status.className = 'api-connect-status error';
probeOptions.classList.add('hidden');
connectButton.textContent = 'Подключиться';
return;
}
const hostOn = apiProbeResult.host_powered_on;
const powerControlAvailable = apiProbeResult.power_control_available;
if (hostOn) {
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включён.';
status.className = 'api-connect-status success';
} else {
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.';
status.className = 'api-connect-status warning';
}
probeOptions.classList.remove('hidden');
// "Включить" checkbox
if (powerOnCheckbox) {
if (hostOn) {
// Host already on — checkbox is checked and disabled
powerOnCheckbox.checked = true;
powerOnCheckbox.disabled = true;
} else {
// Host off — default: checked (will power on), enabled
powerOnCheckbox.checked = true;
powerOnCheckbox.disabled = !powerControlAvailable;
}
}
// "Выключить" checkbox — default: unchecked
if (powerOffCheckbox) {
powerOffCheckbox.checked = false;
powerOffCheckbox.disabled = !powerControlAvailable;
}
connectButton.textContent = 'Переподключиться';
}
function resetApiProbeState() {
apiProbeResult = null;
clearApiPowerDecisionTimer();
const connectButton = document.getElementById('api-connect-btn');
const probeOptions = document.getElementById('api-probe-options');
if (connectButton) {
connectButton.textContent = 'Подключиться';
}
if (probeOptions) {
probeOptions.classList.add('hidden');
}
}
function clearApiPowerDecisionTimer() {
if (!apiPowerDecisionTimer) {
return;
}
window.clearInterval(apiPowerDecisionTimer);
apiPowerDecisionTimer = null;
}
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 = `Исправьте ошибки в форме:${messages.map(msg => `- ${escapeHtml(msg)}
`).join('')}
`;
}
function renderApiConnectStatus(isValid) {
const status = document.getElementById('api-connect-status');
if (!status) {
return;
}
if (!isValid) {
status.textContent = 'Форма не отправлена: есть ошибки.';
status.className = 'api-connect-status error';
return;
}
status.textContent = 'Подключение...';
status.className = 'api-connect-status info';
}
function clearApiConnectStatus() {
const status = document.getElementById('api-connect-status');
if (!status) {
return;
}
status.textContent = '';
status.className = 'api-connect-status';
}
function startCollectionJob(payload) {
clearApiPowerDecisionTimer();
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,
currentPhase: '',
etaSeconds: null,
logs: [],
activeModules: [],
moduleScores: [],
debugInfo: null,
payload
};
appendJobLog(body.message || 'Задача поставлена в очередь');
renderCollectionJob();
collectionJobPollTimer = window.setInterval(() => {
pollCollectionJobStatus();
}, 1200);
})
.catch((err) => {
setApiFormBlocked(false);
clearApiConnectStatus();
renderApiConnectStatus(false);
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.currentPhase = body.current_phase || collectionJob.currentPhase || '';
collectionJob.etaSeconds = Number.isFinite(body.eta_seconds) ? body.eta_seconds : collectionJob.etaSeconds;
collectionJob.error = body.error || '';
collectionJob.activeModules = Array.isArray(body.active_modules) ? body.active_modules : collectionJob.activeModules;
collectionJob.moduleScores = Array.isArray(body.module_scores) ? body.module_scores : collectionJob.moduleScores;
collectionJob.debugInfo = body.debug_info || collectionJob.debugInfo || null;
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 parsed = parseServerLogLine(message);
if (isCollectLogNoise(parsed.message)) {
// Still count toward log length so syncServerLogs offset stays correct,
// but mark as hidden so renderCollectionJob skips it.
collectionJob.logs.push({
id: ++collectionJobLogCounter,
time: parsed.time || new Date().toLocaleTimeString('ru-RU', { hour12: false }),
message: parsed.message,
hidden: true
});
return;
}
collectionJob.logs.push({
id: ++collectionJobLogCounter,
time: parsed.time || new Date().toLocaleTimeString('ru-RU', { hour12: false }),
message: humanizeCollectLogMessage(parsed.message)
});
}
// Transform technical log messages into human-readable form for the UI.
// The original messages are preserved in collect.log / raw_export.
function humanizeCollectLogMessage(msg) {
// "Redfish snapshot: документов=520, ETA≈16s, корни=Chassis(294), Systems(114), последний=/redfish/v1/..."
// → "Snapshot: /Chassis/Self/PCIeDevices/00_34_04"
let m = msg.match(/snapshot:\s+документов=\d+[^,]*,.*последний=(\S+)/i);
if (m) {
const path = m[1].replace(/^\.\.\./, '').replace(/^\/redfish\/v1/, '') || m[1];
return `Snapshot: ${path}`;
}
// "Redfish snapshot: собрано N документов"
m = msg.match(/snapshot:\s+собрано\s+(\d+)\s+документов/i);
if (m) {
return `Snapshot: итого ${m[1]} документов`;
}
// "Redfish: plan-B завершен за 30s (targets=18, recovered=0)"
m = msg.match(/plan-B завершен за ([^(]+)\(targets=(\d+),\s*recovered=(\d+)\)/i);
if (m) {
const recovered = parseInt(m[3], 10);
const suffix = recovered > 0 ? `, восстановлено ${m[3]}` : '';
return `Plan-B: завершен за ${m[1].trim()}${suffix}`;
}
// "Redfish: prefetch критичных endpoint (адаптивно 9/72)..."
m = msg.match(/prefetch критичных endpoint[^(]*\(([^)]+)\)/i);
if (m) {
return `Prefetch критичных endpoint (${m[1]})`;
}
// Strip "Redfish: " / "Redfish snapshot: " prefix — redundant in context
return msg.replace(/^Redfish(?:\s+snapshot)?:\s+/i, '');
}
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 activeModulesBlock = document.getElementById('job-active-modules');
const activeModulesList = document.getElementById('job-active-modules-list');
const debugInfoBlock = document.getElementById('job-debug-info');
const debugSummary = document.getElementById('job-debug-summary');
const phaseTelemetryNode = document.getElementById('job-phase-telemetry');
const logsList = document.getElementById('job-logs-list');
const cancelButton = document.getElementById('cancel-job-btn');
if (!jobStatusBlock || !jobIdValue || !statusValue || !progressValue || !etaValue || !progressBar || !activeModulesBlock || !activeModulesList || !debugInfoBlock || !debugSummary || !phaseTelemetryNode || !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}%`;
renderJobActiveModules(activeModulesBlock, activeModulesList);
renderJobDebugInfo(debugInfoBlock, debugSummary, phaseTelemetryNode);
logsList.innerHTML = [...collectionJob.logs].reverse()
.filter((log) => !log.hidden)
.map((log) => (
`${escapeHtml(log.time)}${escapeHtml(log.message)}`
)).join('');
cancelButton.disabled = isTerminal;
setApiFormBlocked(!isTerminal);
}
function latestCollectionActivityMessage() {
if (collectionJob && collectionJob.currentPhase) {
return humanizeCollectionPhase(collectionJob.currentPhase);
}
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 && Number.isFinite(collectionJob.etaSeconds) && collectionJob.etaSeconds > 0) {
return formatDurationSeconds(collectionJob.etaSeconds);
}
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]);
}
}
// Patterns for log lines that are internal debug noise and should not be shown in the UI.
const _collectLogNoisePatterns = [
/plan-B \(\d+\/\d+/, // individual plan-B step lines
/plan-B топ веток/,
/snapshot: heartbeat/,
/snapshot: post-probe коллекций \(/,
/snapshot: топ веток/,
/prefetch завершен/,
/cooldown перед повторным добором/,
/Redfish telemetry:/,
/redfish-postprobe-metrics:/,
/redfish-prefetch-metrics:/,
/redfish-collect:/,
/redfish-profile-plan:/,
/redfish replay:/,
];
function isCollectLogNoise(message) {
return _collectLogNoisePatterns.some((re) => re.test(message));
}
// Strip the server-side RFC3339Nano timestamp prefix from a log line and return {time, message}.
function parseServerLogLine(raw) {
const m = String(raw).match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s+(.*)/s);
if (!m) {
return { time: null, message: String(raw).trim() };
}
const d = new Date(m[1]);
const time = isNaN(d) ? null : d.toLocaleTimeString('ru-RU', { hour12: false });
return { time, message: m[2].trim() };
}
function normalizeJobStatus(status) {
return String(status || '').trim().toLowerCase();
}
function humanizeCollectionPhase(phase) {
const value = String(phase || '').trim().toLowerCase();
return {
discovery: 'Discovery',
snapshot: 'Snapshot',
snapshot_postprobe_nvme: 'Snapshot NVMe post-probe',
snapshot_postprobe_collections: 'Snapshot collection post-probe',
prefetch: 'Prefetch critical endpoints',
critical_plan_b: 'Critical plan-B',
profile_plan_b: 'Profile plan-B'
}[value] || value || 'Сбор данных...';
}
function formatDurationSeconds(totalSeconds) {
const seconds = Math.max(0, Math.round(Number(totalSeconds) || 0));
if (seconds <= 0) {
return '-';
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes === 0) {
return `${remainingSeconds}s`;
}
if (remainingSeconds === 0) {
return `${minutes}m`;
}
return `${minutes}m ${remainingSeconds}s`;
}
function renderJobActiveModules(activeModulesBlock, activeModulesList) {
const activeModules = collectionJob && Array.isArray(collectionJob.activeModules) ? collectionJob.activeModules : [];
if (activeModules.length === 0) {
activeModulesBlock.classList.add('hidden');
activeModulesList.innerHTML = '';
return;
}
activeModulesBlock.classList.remove('hidden');
activeModulesList.innerHTML = activeModules.map((module) => {
const score = Number.isFinite(module.score) ? module.score : 0;
return `
${escapeHtml(module.name || '-')}
${escapeHtml(String(score))}
`;
}).join('');
}
function renderJobDebugInfo(debugInfoBlock, debugSummary, phaseTelemetryNode) {
const debug = collectionJob && collectionJob.debugInfo ? collectionJob.debugInfo : null;
if (!debug) {
debugInfoBlock.classList.add('hidden');
debugSummary.innerHTML = '';
phaseTelemetryNode.innerHTML = '';
return;
}
debugInfoBlock.classList.remove('hidden');
const throttled = debug.adaptive_throttled ? 'on' : 'off';
const prefetchEnabled = typeof debug.prefetch_enabled === 'boolean' ? String(debug.prefetch_enabled) : 'auto';
debugSummary.innerHTML = `adaptive_throttling=${escapeHtml(throttled)}, snapshot_workers=${escapeHtml(String(debug.snapshot_workers || 0))}, prefetch_workers=${escapeHtml(String(debug.prefetch_workers || 0))}, prefetch_enabled=${escapeHtml(prefetchEnabled)}`;
const phases = Array.isArray(debug.phase_telemetry) ? debug.phase_telemetry : [];
if (phases.length === 0) {
phaseTelemetryNode.innerHTML = '';
return;
}
phaseTelemetryNode.innerHTML = phases.map((item) => (
`
${escapeHtml(humanizeCollectionPhase(item.phase || ''))}
req=${escapeHtml(String(item.requests || 0))}
err=${escapeHtml(String(item.errors || 0))}
avg=${escapeHtml(String(item.avg_ms || 0))}ms
p95=${escapeHtml(String(item.p95_ms || 0))}ms
`
)).join('');
}
function moduleTitle(activeModule) {
const name = String(activeModule && activeModule.name || '').trim();
const scores = collectionJob && Array.isArray(collectionJob.moduleScores) ? collectionJob.moduleScores : [];
const full = scores.find((item) => String(item && item.name || '').trim() === name);
if (!full) {
return name;
}
const state = full.active ? 'active' : 'inactive';
return `${name}: score=${Number.isFinite(full.score) ? full.score : 0}, priority=${Number.isFinite(full.priority) ? full.priority : 0}, ${state}`;
}
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', async () => {
const raw = Array.from(folderInput.files || []).filter(file => file && file.name);
const summary = document.getElementById('convert-folder-summary');
if (summary) {
summary.textContent = 'Проверка дубликатов…';
summary.className = 'api-connect-status';
}
const { unique, duplicates } = await deduplicateConvertFiles(raw);
convertFiles = unique;
convertDuplicates = duplicates;
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 selectedFiles = convertFiles.filter(file => file && file.name);
const supportedFiles = selectedFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name));
const skippedCount = selectedFiles.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}.` : '';
const batchCount = Math.ceil(supportedFiles.length / CONVERT_MAX_FILES_PER_BATCH);
const batchesText = batchCount > 1 ? ` Будет ${batchCount} прохода(ов) по ${CONVERT_MAX_FILES_PER_BATCH} файлов.` : '';
let dupText = '';
if (convertDuplicates.length > 0) {
const names = convertDuplicates.map(d => escapeHtml(d.name)).join(', ');
const reasons = convertDuplicates.map(d => d.reason === 'hash' ? 'одинаковое содержимое' : 'одинаковое имя');
const uniqueReasons = [...new Set(reasons)].join(', ');
dupText = ` ⚠ Пропущено дубликатов: ${convertDuplicates.length} (${uniqueReasons}): ${names}.`;
}
summary.innerHTML = `${supportedFiles.length} файлов готовы к конвертации.${previewText ? ` ${previewText}` : ''}${remaining > 0 ? ` и ещё ${remaining}` : ''}.${skippedText}${batchesText}${dupText}`;
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 selectedFiles = convertFiles.filter(file => file && file.name);
const supportedFiles = selectedFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name));
if (supportedFiles.length === 0) {
renderConvertStatus('В выбранной папке нет файлов поддерживаемого типа', 'error');
return;
}
const batches = chunkFiles(supportedFiles, CONVERT_MAX_FILES_PER_BATCH);
isConvertRunning = true;
runButton.disabled = true;
renderConvertProgress(0, 'Подготовка загрузки...');
renderConvertStatus(`Выполняю пакетную конвертацию (${batches.length} проходов)...`, 'info');
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const passSummaries = [];
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
const batchFiles = batches[batchIdx];
const pass = batchIdx + 1;
const passLabel = `Проход ${pass}/${batches.length}`;
const passStart = Math.round((batchIdx / batches.length) * 100);
const passEnd = Math.round(((batchIdx + 1) / batches.length) * 100);
const formData = new FormData();
batchFiles.forEach(file => {
const relativePath = file.webkitRelativePath || file.name || 'file';
formData.append('files[]', file, relativePath);
});
const startResponse = await uploadConvertBatch(formData, (percent) => {
const clamped = Math.max(0, Math.min(100, Number(percent) || 0));
const uploadPhase = passStart + Math.round((passEnd - passStart) * 0.3 * (clamped / 100));
renderConvertProgress(uploadPhase, `${passLabel}: загрузка ${clamped}%`);
});
if (!startResponse.ok) {
const errorPayload = parseConvertErrorPayload(startResponse.bodyText);
hideConvertProgress();
renderConvertStatus(`${passLabel}: ${errorPayload.error || 'пакетная конвертация завершилась с ошибкой'}`, 'error');
return;
}
if (!startResponse.jobId) {
hideConvertProgress();
renderConvertStatus(`${passLabel}: сервер не вернул идентификатор задачи`, 'error');
return;
}
await waitForConvertJob(startResponse.jobId, (statusPayload) => {
const serverProgress = Math.max(0, Math.min(100, Number(statusPayload.progress || 0)));
const phase = 0.3 + 0.7 * (serverProgress / 100);
const combined = passStart + Math.round((passEnd - passStart) * phase);
renderConvertProgress(combined, `${passLabel}: конвертация ${serverProgress}%`);
});
const downloadResponse = await downloadConvertArchive(startResponse.jobId);
if (!downloadResponse.ok) {
const errorPayload = parseConvertErrorPayload(downloadResponse.bodyText);
hideConvertProgress();
renderConvertStatus(`${passLabel}: ${errorPayload.error || 'не удалось скачать результат'}`, 'error');
return;
}
const suffix = batches.length > 1 ? `-part${pass}` : '';
downloadBlob(downloadResponse.blob, `logpile-convert-${timestamp}${suffix}.zip`);
passSummaries.push(downloadResponse.summaryHeader || `${passLabel}: завершено`);
}
hideConvertProgress();
renderConvertStatus(passSummaries.join(' | '), 'success');
} catch (err) {
hideConvertProgress();
renderConvertStatus('Ошибка соединения при конвертации', 'error');
} finally {
isConvertRunning = false;
runButton.disabled = false;
}
}
function chunkFiles(files, chunkSize) {
const safeChunkSize = Math.max(1, Number(chunkSize) || 1);
const chunks = [];
for (let i = 0; i < files.length; i += safeChunkSize) {
chunks.push(files.slice(i, i + safeChunkSize));
}
return chunks;
}
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 {};
}
}
async function deduplicateConvertFiles(files) {
// First pass: deduplicate by basename
const seenNames = new Map(); // name -> index in unique
const unique = [];
const duplicates = [];
for (const file of files) {
if (seenNames.has(file.name)) {
duplicates.push({ name: file.webkitRelativePath || file.name, reason: 'name' });
} else {
seenNames.set(file.name, unique.length);
unique.push(file);
}
}
// Second pass: deduplicate by SHA-256 hash
const seenHashes = new Map(); // hash -> file.name
const hashUnique = [];
for (const file of unique) {
try {
const buf = await file.arrayBuffer();
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
const hash = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
if (seenHashes.has(hash)) {
duplicates.push({ name: file.webkitRelativePath || file.name, reason: 'hash' });
} else {
seenHashes.set(hash, file.name);
hashUnique.push(file);
}
} catch (_) {
hashUnique.push(file);
}
}
return { unique: hashUnique, duplicates };
}
function isSupportedConvertFileName(filename) {
const name = String(filename || '').trim().toLowerCase();
if (!name) {
return false;
}
if (Array.isArray(supportedConvertExtensions) && supportedConvertExtensions.length > 0) {
return supportedConvertExtensions.some(ext => name.endsWith(ext));
}
return true;
}
async function loadSupportedFileTypes() {
try {
const response = await fetch('/api/file-types');
const payload = await response.json();
if (!response.ok) {
return;
}
if (Array.isArray(payload.upload_extensions)) {
supportedUploadExtensions = payload.upload_extensions
.map(ext => String(ext || '').trim().toLowerCase())
.filter(Boolean);
}
if (Array.isArray(payload.convert_extensions)) {
supportedConvertExtensions = payload.convert_extensions
.map(ext => String(ext || '').trim().toLowerCase())
.filter(Boolean);
}
applyUploadAcceptExtensions();
renderConvertSummary();
} catch (err) {
// Keep permissive fallback if endpoint is temporarily unavailable.
}
}
function applyUploadAcceptExtensions() {
const fileInput = document.getElementById('file-input');
if (!fileInput || !Array.isArray(supportedUploadExtensions) || supportedUploadExtensions.length === 0) {
return;
}
fileInput.setAttribute('accept', supportedUploadExtensions.join(','));
}
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 = '';
let auditViewerNonce = 0;
// 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');
document.getElementById('header-raw-btn').classList.remove('hidden');
document.getElementById('header-reanimator-btn').classList.remove('hidden');
document.getElementById('header-log-meta').classList.remove('hidden');
// Update vendor badge if exists (legacy support)
const vendorBadge = document.getElementById('vendor-badge');
if (vendorBadge && currentVendor) {
vendorBadge.textContent = currentVendor;
vendorBadge.classList.remove('hidden');
}
loadAuditViewer();
}
function loadAuditViewer() {
const frame = document.getElementById('audit-viewer-frame');
if (!frame) {
return;
}
auditViewerNonce += 1;
frame.style.height = '60vh';
frame.src = `/chart/current?ts=${auditViewerNonce}`;
}
function resizeAuditViewerFrame() {
const frame = document.getElementById('audit-viewer-frame');
if (!frame) {
return;
}
try {
const doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document);
if (!doc || !doc.documentElement || !doc.body) {
return;
}
const nextHeight = Math.max(
doc.documentElement.scrollHeight,
doc.body.scrollHeight,
640
);
frame.style.height = `${nextHeight}px`;
} catch (err) {
console.error('Failed to resize audit viewer frame:', err);
}
}
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 += '
Спецификация сервера
';
spec.forEach(item => {
html += `- ${escapeHtml(item.category)} ${escapeHtml(item.name)} - ${item.quantity} шт.
`;
});
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 += `
| Socket | Модель | Ядра | Потоки | Частота | Max Turbo | TDP | L3 Cache | PCIe линии (занято) | Модулей памяти | PPIN |
`;
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 += `
| ${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)} |
`;
});
html += '
';
} 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}Активно
| Location | Наличие | Размер | Тип | Max частота | Текущая частота | Производитель | Артикул | Серийный номер | Статус |
`;
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 += `
| ${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')} |
`;
});
html += '
';
} 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 += `
| ${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 || '-')} |
`;
});
html += '
';
} 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(', ') || '-'}По типам
| NO. | Статус | Расположение | Backplane ID | Тип | Модель | Размер | Серийный номер |
`;
storage.forEach(s => {
const presentIcon = s.present ? '●' : '○';
const presentText = s.present ? 'Present' : 'Empty';
html += `
| ${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) + '' : '-'} |
`;
});
html += '
';
if (volumes.length > 0) {
html += `
Логические тома (RAID/VROC)
| ID | Имя | Контроллер | RAID | Размер | Статус |
`;
volumes.forEach(v => {
html += `
| ${escapeHtml(v.id || '-')} |
${escapeHtml(v.name || '-')} |
${escapeHtml(v.controller || '-')} |
${escapeHtml(v.raid_level || '-')} |
${v.size_gb > 0 ? `${v.size_gb} GB` : '-'} |
${escapeHtml(v.status || '-')} |
`;
});
html += '
';
}
} 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)}Модель
| Слот | Модель | Производитель | BDF | PCIe | Серийный номер |
`;
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 += `
| ${escapeHtml(gpu.slot || '-')} |
${escapeHtml(gpu.model || '-')} |
${escapeHtml(gpu.manufacturer || '-')} |
${escapeHtml(gpu.bdf || '-')} |
${pcieLink} |
${escapeHtml(gpu.serial_number || '-')} |
`;
});
html += '
';
} 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(', ') || '-')}Модели
| Слот | Модель | Производитель | Порты | Тип | MAC адреса | Статус |
`;
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 += `
| ${escapeHtml(nic.location || nic.slot || '-')} |
${escapeHtml(nic.model || '-')} |
${escapeHtml(nic.manufacturer || nic.vendor || '-')} |
${displayPortCount ?? '-'} |
${escapeHtml(nic.port_type || '-')} |
${escapeHtml(macs)} |
${escapeHtml(nic.status || '-')} |
`;
});
html += '
';
} 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 += '
| Слот | BDF | Модель | Производитель | Vendor:Device ID | PCIe Link | Серийный номер | Прошивка |
';
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 += `
| ${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 || '-')} |
`;
});
html += '
';
});
} 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 += ``;
});
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('header-raw-btn').classList.add('hidden');
document.getElementById('header-reanimator-btn').classList.add('hidden');
document.getElementById('header-log-meta').classList.add('hidden');
document.getElementById('upload-status').textContent = '';
allSensors = [];
allEvents = [];
allSerials = [];
allParseErrors = [];
currentVendor = '';
const frame = document.getElementById('audit-viewer-frame');
if (frame) {
frame.src = 'about:blank';
}
} 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;
}
}