1554 lines
60 KiB
JavaScript
1554 lines
60 KiB
JavaScript
// LOGPile Frontend Application
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initSourceType();
|
||
initApiSource();
|
||
initUpload();
|
||
initTabs();
|
||
initFilters();
|
||
loadParsersInfo();
|
||
});
|
||
|
||
let sourceType = 'archive';
|
||
let apiConnectPayload = null;
|
||
let collectionJob = null;
|
||
let collectionJobPollTimer = null;
|
||
let collectionJobLogCounter = 0;
|
||
let apiPortTouchedByUser = false;
|
||
let isAutoUpdatingApiPort = false;
|
||
|
||
function initSourceType() {
|
||
const sourceButtons = document.querySelectorAll('.source-switch-btn');
|
||
sourceButtons.forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
setSourceType(button.dataset.sourceType);
|
||
});
|
||
});
|
||
|
||
setSourceType(sourceType);
|
||
}
|
||
|
||
function setSourceType(nextType) {
|
||
sourceType = nextType === 'api' ? 'api' : 'archive';
|
||
|
||
document.querySelectorAll('.source-switch-btn').forEach(button => {
|
||
button.classList.toggle('active', button.dataset.sourceType === sourceType);
|
||
});
|
||
|
||
const archiveContent = document.getElementById('archive-source-content');
|
||
const apiSourceContent = document.getElementById('api-source-content');
|
||
archiveContent.classList.toggle('hidden', sourceType !== 'archive');
|
||
apiSourceContent.classList.toggle('hidden', sourceType !== 'api');
|
||
}
|
||
|
||
function initApiSource() {
|
||
const apiForm = document.getElementById('api-connect-form');
|
||
if (!apiForm) {
|
||
return;
|
||
}
|
||
|
||
const cancelJobButton = document.getElementById('cancel-job-btn');
|
||
const fieldNames = ['host', 'port', 'username', 'password'];
|
||
|
||
apiForm.addEventListener('submit', (event) => {
|
||
event.preventDefault();
|
||
const { isValid, payload, errors } = validateCollectForm();
|
||
renderFormErrors(errors);
|
||
|
||
if (!isValid) {
|
||
renderApiConnectStatus(false, null);
|
||
apiConnectPayload = null;
|
||
return;
|
||
}
|
||
|
||
apiConnectPayload = payload;
|
||
renderApiConnectStatus(true, payload);
|
||
startCollectionJob(payload);
|
||
});
|
||
|
||
if (cancelJobButton) {
|
||
cancelJobButton.addEventListener('click', () => {
|
||
cancelCollectionJob();
|
||
});
|
||
}
|
||
|
||
fieldNames.forEach((fieldName) => {
|
||
const field = apiForm.elements.namedItem(fieldName);
|
||
if (!field) {
|
||
return;
|
||
}
|
||
|
||
const eventName = field.tagName.toLowerCase() === 'select' ? 'change' : 'input';
|
||
field.addEventListener(eventName, () => {
|
||
if (fieldName === 'port') {
|
||
handleApiPortInput(field.value);
|
||
}
|
||
|
||
const { errors } = validateCollectForm();
|
||
renderFormErrors(errors);
|
||
clearApiConnectStatus();
|
||
|
||
if (collectionJob && isCollectionJobTerminal(collectionJob.status)) {
|
||
resetCollectionJobState();
|
||
}
|
||
});
|
||
});
|
||
|
||
applyRedfishDefaultPort();
|
||
renderCollectionJob();
|
||
}
|
||
|
||
function validateCollectForm() {
|
||
const host = getApiValue('host');
|
||
const portRaw = getApiValue('port');
|
||
const username = getApiValue('username');
|
||
const password = getApiValue('password');
|
||
|
||
const errors = {};
|
||
|
||
if (!host) {
|
||
errors.host = 'Укажите host.';
|
||
}
|
||
|
||
const port = Number(portRaw);
|
||
const isPortInteger = Number.isInteger(port);
|
||
if (!portRaw) {
|
||
errors.port = 'Укажите порт.';
|
||
} else if (!isPortInteger || port < 1 || port > 65535) {
|
||
errors.port = 'Порт должен быть от 1 до 65535.';
|
||
}
|
||
|
||
if (!username) {
|
||
errors.username = 'Укажите username.';
|
||
}
|
||
|
||
if (!password) {
|
||
errors.password = 'Введите пароль.';
|
||
}
|
||
|
||
if (Object.keys(errors).length > 0) {
|
||
return { isValid: false, errors, payload: null };
|
||
}
|
||
|
||
// TODO: UI для выбора протокола вернем, когда откроем IPMI коннектор.
|
||
const payload = {
|
||
host,
|
||
protocol: 'redfish',
|
||
port,
|
||
username,
|
||
auth_type: 'password',
|
||
tls_mode: 'insecure',
|
||
password
|
||
};
|
||
|
||
return { isValid: true, errors: {}, payload };
|
||
}
|
||
|
||
function renderFormErrors(errors) {
|
||
const apiForm = document.getElementById('api-connect-form');
|
||
const summary = document.getElementById('api-form-errors');
|
||
if (!apiForm || !summary) {
|
||
return;
|
||
}
|
||
|
||
const errorFields = ['host', 'port', 'username', 'password'];
|
||
errorFields.forEach((fieldName) => {
|
||
const errorNode = apiForm.querySelector(`[data-error-for="${fieldName}"]`);
|
||
if (!errorNode) {
|
||
return;
|
||
}
|
||
|
||
const fieldWrapper = errorNode.closest('.api-form-field');
|
||
const message = errors[fieldName] || '';
|
||
errorNode.textContent = message;
|
||
if (fieldWrapper) {
|
||
fieldWrapper.classList.toggle('has-error', Boolean(message));
|
||
}
|
||
});
|
||
|
||
const messages = Object.values(errors);
|
||
if (messages.length === 0) {
|
||
summary.innerHTML = '';
|
||
summary.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
summary.classList.remove('hidden');
|
||
summary.innerHTML = `<strong>Исправьте ошибки в форме:</strong><ul>${messages.map(msg => `<li>${escapeHtml(msg)}</li>`).join('')}</ul>`;
|
||
}
|
||
|
||
function renderApiConnectStatus(isValid, payload) {
|
||
const status = document.getElementById('api-connect-status');
|
||
if (!status) {
|
||
return;
|
||
}
|
||
|
||
if (!isValid) {
|
||
status.textContent = 'Форма не отправлена: есть ошибки.';
|
||
status.className = 'api-connect-status error';
|
||
return;
|
||
}
|
||
|
||
const payloadPreview = { ...payload };
|
||
if (payloadPreview.password) {
|
||
payloadPreview.password = '***';
|
||
}
|
||
if (payloadPreview.token) {
|
||
payloadPreview.token = '***';
|
||
}
|
||
|
||
status.textContent = `Payload сформирован: ${JSON.stringify(payloadPreview)}`;
|
||
status.className = 'api-connect-status success';
|
||
}
|
||
|
||
function clearApiConnectStatus() {
|
||
const status = document.getElementById('api-connect-status');
|
||
if (!status) {
|
||
return;
|
||
}
|
||
|
||
status.textContent = '';
|
||
status.className = 'api-connect-status';
|
||
}
|
||
|
||
function startCollectionJob(payload) {
|
||
resetCollectionJobState();
|
||
setApiFormBlocked(true);
|
||
|
||
fetch('/api/collect', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.then(async (response) => {
|
||
const body = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(body.error || 'Не удалось запустить задачу');
|
||
}
|
||
|
||
collectionJob = {
|
||
id: body.job_id,
|
||
status: normalizeJobStatus(body.status || 'queued'),
|
||
progress: 0,
|
||
logs: [],
|
||
payload
|
||
};
|
||
appendJobLog(body.message || 'Задача поставлена в очередь');
|
||
renderCollectionJob();
|
||
|
||
collectionJobPollTimer = window.setInterval(() => {
|
||
pollCollectionJobStatus();
|
||
}, 1200);
|
||
})
|
||
.catch((err) => {
|
||
setApiFormBlocked(false);
|
||
clearApiConnectStatus();
|
||
renderApiConnectStatus(false, null);
|
||
const status = document.getElementById('api-connect-status');
|
||
if (status) {
|
||
status.textContent = err.message || 'Ошибка запуска задачи';
|
||
status.className = 'api-connect-status error';
|
||
}
|
||
});
|
||
}
|
||
|
||
function pollCollectionJobStatus() {
|
||
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
|
||
clearCollectionJobPolling();
|
||
return;
|
||
}
|
||
|
||
fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}`)
|
||
.then(async (response) => {
|
||
const body = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(body.error || 'Не удалось получить статус задачи');
|
||
}
|
||
|
||
const prevStatus = collectionJob.status;
|
||
collectionJob.status = normalizeJobStatus(body.status || collectionJob.status);
|
||
collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress;
|
||
collectionJob.error = body.error || '';
|
||
syncServerLogs(body.logs);
|
||
renderCollectionJob();
|
||
|
||
if (isCollectionJobTerminal(collectionJob.status)) {
|
||
clearCollectionJobPolling();
|
||
if (collectionJob.status === 'success') {
|
||
loadDataFromStatus();
|
||
} else if (collectionJob.status === 'failed' && collectionJob.error) {
|
||
appendJobLog(`Ошибка: ${collectionJob.error}`);
|
||
renderCollectionJob();
|
||
}
|
||
} else if (prevStatus !== collectionJob.status && collectionJob.status === 'running') {
|
||
appendJobLog('Сбор выполняется...');
|
||
renderCollectionJob();
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
appendJobLog(`Ошибка статуса: ${err.message}`);
|
||
renderCollectionJob();
|
||
clearCollectionJobPolling();
|
||
setApiFormBlocked(false);
|
||
});
|
||
}
|
||
|
||
function cancelCollectionJob() {
|
||
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
|
||
return;
|
||
}
|
||
fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}/cancel`, {
|
||
method: 'POST'
|
||
})
|
||
.then(async (response) => {
|
||
const body = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(body.error || 'Не удалось отменить задачу');
|
||
}
|
||
collectionJob.status = normalizeJobStatus(body.status || 'canceled');
|
||
collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress;
|
||
syncServerLogs(body.logs);
|
||
clearCollectionJobPolling();
|
||
renderCollectionJob();
|
||
})
|
||
.catch((err) => {
|
||
appendJobLog(`Ошибка отмены: ${err.message}`);
|
||
renderCollectionJob();
|
||
});
|
||
}
|
||
|
||
function appendJobLog(message) {
|
||
if (!collectionJob) {
|
||
return;
|
||
}
|
||
|
||
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
||
collectionJob.logs.push({
|
||
id: ++collectionJobLogCounter,
|
||
time,
|
||
message
|
||
});
|
||
}
|
||
|
||
function renderCollectionJob() {
|
||
const jobStatusBlock = document.getElementById('api-job-status');
|
||
const jobIdValue = document.getElementById('job-id-value');
|
||
const statusValue = document.getElementById('job-status-value');
|
||
const progressValue = document.getElementById('job-progress-value');
|
||
const logsList = document.getElementById('job-logs-list');
|
||
const cancelButton = document.getElementById('cancel-job-btn');
|
||
if (!jobStatusBlock || !jobIdValue || !statusValue || !progressValue || !logsList || !cancelButton) {
|
||
return;
|
||
}
|
||
|
||
if (!collectionJob) {
|
||
jobStatusBlock.classList.add('hidden');
|
||
setApiFormBlocked(false);
|
||
return;
|
||
}
|
||
|
||
jobStatusBlock.classList.remove('hidden');
|
||
jobIdValue.textContent = collectionJob.id;
|
||
statusValue.textContent = collectionJob.status;
|
||
statusValue.className = `job-status-badge status-${collectionJob.status.toLowerCase()}`;
|
||
const isTerminal = isCollectionJobTerminal(collectionJob.status);
|
||
const terminalMessage = {
|
||
success: 'Сбор завершен',
|
||
failed: 'Сбор завершился ошибкой',
|
||
canceled: 'Сбор отменен'
|
||
}[collectionJob.status];
|
||
const progressLabel = isTerminal
|
||
? terminalMessage
|
||
: 'Сбор данных...';
|
||
progressValue.textContent = `${collectionJob.progress}% · ${progressLabel}`;
|
||
|
||
logsList.innerHTML = collectionJob.logs.map((log) => (
|
||
`<li><span class="log-time">${escapeHtml(log.time)}</span><span class="log-message">${escapeHtml(log.message)}</span></li>`
|
||
)).join('');
|
||
|
||
cancelButton.disabled = isTerminal;
|
||
setApiFormBlocked(!isTerminal);
|
||
}
|
||
|
||
function isCollectionJobTerminal(status) {
|
||
return ['success', 'failed', 'canceled'].includes(normalizeJobStatus(status));
|
||
}
|
||
|
||
function setApiFormBlocked(shouldBlock) {
|
||
const apiForm = document.getElementById('api-connect-form');
|
||
if (!apiForm) {
|
||
return;
|
||
}
|
||
|
||
apiForm.classList.toggle('is-disabled', shouldBlock);
|
||
Array.from(apiForm.elements).forEach((field) => {
|
||
field.disabled = shouldBlock;
|
||
});
|
||
}
|
||
|
||
function clearCollectionJobPolling() {
|
||
if (!collectionJobPollTimer) {
|
||
return;
|
||
}
|
||
|
||
window.clearInterval(collectionJobPollTimer);
|
||
collectionJobPollTimer = null;
|
||
}
|
||
|
||
function resetCollectionJobState() {
|
||
clearCollectionJobPolling();
|
||
collectionJob = null;
|
||
renderCollectionJob();
|
||
}
|
||
|
||
function syncServerLogs(logs) {
|
||
if (!collectionJob || !Array.isArray(logs)) {
|
||
return;
|
||
}
|
||
if (logs.length <= collectionJob.logs.length) {
|
||
return;
|
||
}
|
||
|
||
const from = collectionJob.logs.length;
|
||
for (let i = from; i < logs.length; i += 1) {
|
||
appendJobLog(logs[i]);
|
||
}
|
||
}
|
||
|
||
function normalizeJobStatus(status) {
|
||
return String(status || '').trim().toLowerCase();
|
||
}
|
||
|
||
async function loadDataFromStatus() {
|
||
try {
|
||
const response = await fetch('/api/status');
|
||
const payload = await response.json();
|
||
if (!payload.loaded) {
|
||
return;
|
||
}
|
||
const vendor = payload.vendor || payload.protocol || '';
|
||
const filename = payload.filename || (payload.protocol && payload.target_host
|
||
? `${payload.protocol}://${payload.target_host}`
|
||
: '');
|
||
await loadData(vendor, filename);
|
||
} catch (err) {
|
||
console.error('Failed to load data after collect:', err);
|
||
}
|
||
}
|
||
|
||
function applyRedfishDefaultPort() {
|
||
const apiForm = document.getElementById('api-connect-form');
|
||
if (!apiForm) {
|
||
return;
|
||
}
|
||
|
||
const portField = apiForm.elements.namedItem('port');
|
||
if (!portField || typeof portField.value !== 'string') {
|
||
return;
|
||
}
|
||
|
||
const currentValue = portField.value.trim();
|
||
if (apiPortTouchedByUser && currentValue !== '') {
|
||
return;
|
||
}
|
||
|
||
isAutoUpdatingApiPort = true;
|
||
portField.value = '443';
|
||
isAutoUpdatingApiPort = false;
|
||
}
|
||
|
||
function handleApiPortInput(value) {
|
||
if (isAutoUpdatingApiPort) {
|
||
return;
|
||
}
|
||
apiPortTouchedByUser = value.trim() !== '';
|
||
}
|
||
|
||
function getApiValue(fieldName) {
|
||
const apiForm = document.getElementById('api-connect-form');
|
||
if (!apiForm) {
|
||
return '';
|
||
}
|
||
|
||
const field = apiForm.elements.namedItem(fieldName);
|
||
if (!field || typeof field.value !== 'string') {
|
||
return '';
|
||
}
|
||
return field.value.trim();
|
||
}
|
||
|
||
// Load and display available parsers
|
||
async function loadParsersInfo() {
|
||
try {
|
||
const response = await fetch('/api/parsers');
|
||
const data = await response.json();
|
||
const container = document.getElementById('parsers-info');
|
||
|
||
if (data.parsers && data.parsers.length > 0) {
|
||
let html = '<p class="parsers-title">Поддерживаемые платформы:</p><div class="parsers-list">';
|
||
data.parsers.forEach(p => {
|
||
html += `<div class="parser-item">
|
||
<span class="parser-name">${escapeHtml(p.name)}</span>
|
||
<span class="parser-version">v${escapeHtml(p.version)}</span>
|
||
</div>`;
|
||
});
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load parsers info:', err);
|
||
}
|
||
}
|
||
|
||
// Upload handling
|
||
function initUpload() {
|
||
const dropZone = document.getElementById('drop-zone');
|
||
const fileInput = document.getElementById('file-input');
|
||
|
||
dropZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
dropZone.classList.add('dragover');
|
||
});
|
||
|
||
dropZone.addEventListener('dragleave', () => {
|
||
dropZone.classList.remove('dragover');
|
||
});
|
||
|
||
dropZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dropZone.classList.remove('dragover');
|
||
const files = e.dataTransfer.files;
|
||
if (files.length > 0) {
|
||
uploadFile(files[0]);
|
||
}
|
||
});
|
||
|
||
fileInput.addEventListener('change', () => {
|
||
if (fileInput.files.length > 0) {
|
||
uploadFile(fileInput.files[0]);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function uploadFile(file) {
|
||
const status = document.getElementById('upload-status');
|
||
status.textContent = 'Загрузка и анализ...';
|
||
status.className = '';
|
||
|
||
const formData = new FormData();
|
||
formData.append('archive', file);
|
||
|
||
try {
|
||
const response = await fetch('/api/upload', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
status.innerHTML = `<strong>${escapeHtml(result.vendor)}</strong><br>` +
|
||
`${result.stats.sensors} сенсоров, ${result.stats.fru} компонентов, ${result.stats.events} событий`;
|
||
status.className = 'success';
|
||
loadData(result.vendor, result.filename);
|
||
} else {
|
||
status.textContent = result.error || 'Ошибка загрузки';
|
||
status.className = 'error';
|
||
}
|
||
} catch (err) {
|
||
status.textContent = 'Ошибка соединения';
|
||
status.className = 'error';
|
||
}
|
||
}
|
||
|
||
// Tab navigation
|
||
function initTabs() {
|
||
const tabs = document.querySelectorAll('.tab');
|
||
tabs.forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
tabs.forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
document.getElementById(tab.dataset.tab).classList.add('active');
|
||
});
|
||
});
|
||
}
|
||
|
||
// Filters
|
||
function initFilters() {
|
||
document.getElementById('sensor-filter').addEventListener('change', (e) => {
|
||
filterSensors(e.target.value);
|
||
});
|
||
document.getElementById('severity-filter').addEventListener('change', (e) => {
|
||
filterEvents(e.target.value);
|
||
});
|
||
document.getElementById('serial-filter').addEventListener('change', (e) => {
|
||
filterSerials(e.target.value);
|
||
});
|
||
}
|
||
|
||
let allSensors = [];
|
||
let allEvents = [];
|
||
let allSerials = [];
|
||
let 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 = '<p class="no-data">Нет данных о конфигурации</p>';
|
||
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 += `<div class="server-info">
|
||
<div class="server-info-item"><span class="server-info-label">Модель сервера:</span> <strong>${escapeHtml(config.board.product_name || '-')}</strong></div>
|
||
<div class="server-info-item"><span class="server-info-label">Серийный номер:</span> <code>${escapeHtml(config.board.serial_number || '-')}</code></div>
|
||
</div>`;
|
||
}
|
||
|
||
// Configuration sub-tabs
|
||
html += `<div class="config-tabs">
|
||
<button class="config-tab active" data-config-tab="spec">Спецификация</button>
|
||
<button class="config-tab" data-config-tab="cpu">CPU</button>
|
||
<button class="config-tab" data-config-tab="memory">Memory</button>
|
||
<button class="config-tab" data-config-tab="power">Power</button>
|
||
<button class="config-tab" data-config-tab="storage">Hard Drive</button>
|
||
<button class="config-tab" data-config-tab="gpu">GPU</button>
|
||
<button class="config-tab" data-config-tab="network">Network</button>
|
||
<button class="config-tab" data-config-tab="pcie">Device Inventory</button>
|
||
</div>`;
|
||
|
||
// Specification tab
|
||
html += '<div class="config-tab-content active" id="config-spec">';
|
||
const partialInventory = detectPartialRedfishInventory({
|
||
cpus,
|
||
memory,
|
||
redfishFetchErrors
|
||
});
|
||
if (partialInventory) {
|
||
html += `<div class="spec-section">
|
||
<h3>Частичный инвентарь</h3>
|
||
<p class="no-data" style="margin-top: 0;">${escapeHtml(partialInventory)}</p>
|
||
</div>`;
|
||
}
|
||
if (spec && spec.length > 0) {
|
||
html += '<div class="spec-section"><h3>Спецификация сервера</h3><ul class="spec-list">';
|
||
spec.forEach(item => {
|
||
html += `<li><span class="spec-category">${escapeHtml(item.category)}</span> ${escapeHtml(item.name)} <span class="spec-qty">- ${item.quantity} шт.</span></li>`;
|
||
});
|
||
html += '</ul></div>';
|
||
} else {
|
||
html += '<p class="no-data">Нет данных о спецификации</p>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// CPU tab
|
||
html += '<div class="config-tab-content" id="config-cpu">';
|
||
if (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 += `<h3>Процессоры</h3>
|
||
<div class="section-overview">
|
||
<div class="stat-box"><span class="stat-value">${cpuCount}</span><span class="stat-label">Процессоров</span></div>
|
||
<div class="stat-box"><span class="stat-value">${totalCores}</span><span class="stat-label">Ядер</span></div>
|
||
<div class="stat-box"><span class="stat-value">${totalThreads}</span><span class="stat-label">Потоков</span></div>
|
||
<div class="stat-box"><span class="stat-value">${pcieBalance.totalLanes}</span><span class="stat-label">Занято PCIe линий</span></div>
|
||
<div class="stat-box ${balanceClass}"><span class="stat-value">${balanceLabel}</span><span class="stat-label">Баланс PCIe</span></div>
|
||
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(cpuModel)}</span><span class="stat-label">Модель</span></div>
|
||
</div>
|
||
<div class="pcie-balance-bars">`;
|
||
pcieBalance.perCPU.forEach(cpu => {
|
||
html += `<div class="pcie-balance-row">
|
||
<span class="pcie-balance-cpu">${escapeHtml(cpu.label)}</span>
|
||
<div class="pcie-balance-track"><div class="pcie-balance-fill ${balanceClass}" style="width:${cpu.percent}%"></div></div>
|
||
<span class="pcie-balance-value">${cpu.lanes}</span>
|
||
</div>`;
|
||
});
|
||
html += `</div>
|
||
<table class="config-table"><thead><tr><th>Socket</th><th>Модель</th><th>Ядра</th><th>Потоки</th><th>Частота</th><th>Max Turbo</th><th>TDP</th><th>L3 Cache</th><th>PCIe линии (занято)</th><th>Модулей памяти</th><th>PPIN</th></tr></thead><tbody>`;
|
||
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 += `<tr>
|
||
<td>${escapeHtml(socket)}</td>
|
||
<td>${escapeHtml(cpu.model || '-')}</td>
|
||
<td>${cpu.cores || '-'}</td>
|
||
<td>${cpu.threads || '-'}</td>
|
||
<td>${cpu.frequency_mhz ? cpu.frequency_mhz + ' MHz' : '-'}</td>
|
||
<td>${cpu.max_frequency_mhz ? cpu.max_frequency_mhz + ' MHz' : '-'}</td>
|
||
<td>${tdp !== '-' ? tdp + 'W' : '-'}</td>
|
||
<td>${l3 !== '-' ? l3 + ' MB' : '-'}</td>
|
||
<td>${pcieUsed}</td>
|
||
<td>${memoryModules}</td>
|
||
<td><code>${escapeHtml(ppin)}</code></td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
} else {
|
||
html += '<p class="no-data">Нет данных о процессорах</p>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// Memory tab
|
||
html += '<div class="config-tab-content" id="config-memory">';
|
||
if (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 += `<h3>Модули памяти</h3>
|
||
<div class="memory-overview">
|
||
<div class="stat-box"><span class="stat-value">${totalGB} GB</span><span class="stat-label">Всего</span></div>
|
||
<div class="stat-box"><span class="stat-value">${presentCount}</span><span class="stat-label">Установлено</span></div>
|
||
<div class="stat-box"><span class="stat-value">${workingCount}</span><span class="stat-label">Активно</span></div>
|
||
</div>
|
||
<table class="config-table memory-table"><thead><tr>
|
||
<th>Location</th><th>Наличие</th><th>Размер</th><th>Тип</th><th>Max частота</th><th>Текущая частота</th><th>Производитель</th><th>Артикул</th><th>Серийный номер</th><th>Статус</th>
|
||
</tr></thead><tbody>`;
|
||
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 += `<tr class="${rowClass}">
|
||
<td>${escapeHtml(mem.location || mem.slot)}</td>
|
||
<td class="${presentClass}">${present}</td>
|
||
<td>${sizeGB} GB</td>
|
||
<td>${escapeHtml(mem.type || '-')}</td>
|
||
<td>${(mem.details && mem.details.max_speed_mhz) || '-'} MHz</td>
|
||
<td>${(mem.details && mem.details.current_speed_mhz) || mem.speed_mhz || '-'} MHz</td>
|
||
<td>${escapeHtml(mem.manufacturer || '-')}</td>
|
||
<td><code>${escapeHtml(mem.part_number || '-')}</code></td>
|
||
<td><code>${escapeHtml(mem.serial_number || '-')}</code></td>
|
||
<td class="${statusClass}">${escapeHtml(mem.status || 'OK')}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
} else {
|
||
html += '<p class="no-data">Нет данных о памяти</p>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// Power tab
|
||
html += '<div class="config-tab-content" id="config-power">';
|
||
if (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 += `<h3>Блоки питания</h3>
|
||
<div class="section-overview">
|
||
<div class="stat-box"><span class="stat-value">${psuTotal}</span><span class="stat-label">Всего</span></div>
|
||
<div class="stat-box"><span class="stat-value">${psuPresent}</span><span class="stat-label">Подключено</span></div>
|
||
<div class="stat-box"><span class="stat-value">${psuOK}</span><span class="stat-label">Работает</span></div>
|
||
<div class="stat-box"><span class="stat-value">${psuWattage}W</span><span class="stat-label">Мощность</span></div>
|
||
<div class="stat-box"><span class="stat-value">${psuCurrentPowerLabel}</span><span class="stat-label">Текущая суммарная</span></div>
|
||
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(psuModel)}</span><span class="stat-label">Модель</span></div>
|
||
</div>
|
||
<table class="config-table"><thead><tr><th>Слот</th><th>Производитель</th><th>Модель</th><th>Мощность</th><th>Вход</th><th>Выход</th><th>Напряжение</th><th>Температура</th><th>Статус</th></tr></thead><tbody>`;
|
||
powerSupplies.forEach(psu => {
|
||
const statusClass = psu.status === 'OK' ? '' : 'status-warning';
|
||
html += `<tr>
|
||
<td>${escapeHtml(psu.slot)}</td>
|
||
<td>${escapeHtml(psu.manufacturer || psu.vendor || '-')}</td>
|
||
<td>${escapeHtml(psu.model || '-')}</td>
|
||
<td>${psu.wattage_w || '-'}W</td>
|
||
<td>${psu.input_power_w || '-'}W</td>
|
||
<td>${psu.output_power_w || '-'}W</td>
|
||
<td>${psu.input_voltage ? psu.input_voltage.toFixed(0) : '-'}V</td>
|
||
<td>${psu.temperature_c || '-'}°C</td>
|
||
<td class="${statusClass}">${escapeHtml(psu.status || '-')}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
} else {
|
||
html += '<p class="no-data">Нет данных о блоках питания</p>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// Storage tab
|
||
html += '<div class="config-tab-content" id="config-storage">';
|
||
if (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 += `<h3>Накопители</h3>
|
||
<div class="section-overview">
|
||
<div class="stat-box"><span class="stat-value">${storTotal}</span><span class="stat-label">Всего слотов</span></div>
|
||
<div class="stat-box"><span class="stat-value">${storage.filter(s => s.present).length}</span><span class="stat-label">Установлено</span></div>
|
||
<div class="stat-box"><span class="stat-value">${totalTB > 0 ? totalTB + ' TB' : '-'}</span><span class="stat-label">Объём</span></div>
|
||
<div class="stat-box"><span class="stat-value">${volumes.length}</span><span class="stat-label">Логических томов</span></div>
|
||
<div class="stat-box model-box"><span class="stat-value">${typesSummary.join(', ') || '-'}</span><span class="stat-label">По типам</span></div>
|
||
</div>
|
||
<table class="config-table"><thead><tr><th>NO.</th><th>Статус</th><th>Расположение</th><th>Backplane ID</th><th>Тип</th><th>Модель</th><th>Размер</th><th>Серийный номер</th></tr></thead><tbody>`;
|
||
storage.forEach(s => {
|
||
const presentIcon = s.present ? '<span style="color: #27ae60;">●</span>' : '<span style="color: #95a5a6;">○</span>';
|
||
const presentText = s.present ? 'Present' : 'Empty';
|
||
html += `<tr>
|
||
<td>${escapeHtml(s.slot || '-')}</td>
|
||
<td>${presentIcon} ${presentText}</td>
|
||
<td>${escapeHtml(s.location || '-')}</td>
|
||
<td>${s.details && s.details.backplane_id !== undefined ? s.details.backplane_id : '-'}</td>
|
||
<td>${escapeHtml(s.type || '-')}</td>
|
||
<td>${escapeHtml(s.model || '-')}</td>
|
||
<td>${s.size_gb > 0 ? s.size_gb + ' GB' : '-'}</td>
|
||
<td>${s.serial_number ? '<code>' + escapeHtml(s.serial_number) + '</code>' : '-'}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
if (volumes.length > 0) {
|
||
html += `<h3 style="margin-top:16px;">Логические тома (RAID/VROC)</h3>
|
||
<table class="config-table"><thead><tr><th>ID</th><th>Имя</th><th>Контроллер</th><th>RAID</th><th>Размер</th><th>Статус</th></tr></thead><tbody>`;
|
||
volumes.forEach(v => {
|
||
html += `<tr>
|
||
<td>${escapeHtml(v.id || '-')}</td>
|
||
<td>${escapeHtml(v.name || '-')}</td>
|
||
<td>${escapeHtml(v.controller || '-')}</td>
|
||
<td>${escapeHtml(v.raid_level || '-')}</td>
|
||
<td>${v.size_gb > 0 ? `${v.size_gb} GB` : '-'}</td>
|
||
<td>${escapeHtml(v.status || '-')}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
}
|
||
} else {
|
||
html += '<p class="no-data">Нет данных о накопителях</p>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// GPU tab
|
||
html += '<div class="config-tab-content" id="config-gpu">';
|
||
const gpuRows = gpus;
|
||
if (gpuRows.length > 0) {
|
||
const gpuCount = gpuRows.length;
|
||
const gpuModel = gpuRows[0].model || '-';
|
||
const gpuVendor = gpuRows[0].manufacturer || '-';
|
||
html += `<h3>Графические процессоры</h3>
|
||
<div class="section-overview">
|
||
<div class="stat-box"><span class="stat-value">${gpuCount}</span><span class="stat-label">Всего GPU</span></div>
|
||
<div class="stat-box"><span class="stat-value">${escapeHtml(gpuVendor)}</span><span class="stat-label">Производитель</span></div>
|
||
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(gpuModel)}</span><span class="stat-label">Модель</span></div>
|
||
</div>
|
||
<table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>BDF</th><th>PCIe</th><th>Серийный номер</th></tr></thead><tbody>`;
|
||
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 += `<tr>
|
||
<td>${escapeHtml(gpu.slot || '-')}</td>
|
||
<td>${escapeHtml(gpu.model || '-')}</td>
|
||
<td>${escapeHtml(gpu.manufacturer || '-')}</td>
|
||
<td><code>${escapeHtml(gpu.bdf || '-')}</code></td>
|
||
<td>${pcieLink}</td>
|
||
<td><code>${escapeHtml(gpu.serial_number || '-')}</code></td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
} else {
|
||
html += '<p class="no-data">Нет GPU</p>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// Network tab
|
||
html += '<div class="config-tab-content" id="config-network">';
|
||
const networkRows = networkAdapters;
|
||
if (networkRows.length > 0) {
|
||
const nicCount = networkRows.length;
|
||
const totalPorts = networkRows.reduce((sum, n) => sum + (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 += `<h3>Сетевые адаптеры</h3>
|
||
<div class="section-overview">
|
||
<div class="stat-box"><span class="stat-value">${nicCount}</span><span class="stat-label">Адаптеров</span></div>
|
||
<div class="stat-box"><span class="stat-value">${totalPorts}</span><span class="stat-label">Портов</span></div>
|
||
<div class="stat-box"><span class="stat-value">${nicTypes.join(', ') || '-'}</span><span class="stat-label">Тип портов</span></div>
|
||
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(nicModels.join(', ') || '-')}</span><span class="stat-label">Модели</span></div>
|
||
</div>
|
||
<table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>Порты</th><th>Тип</th><th>MAC адреса</th><th>Статус</th></tr></thead><tbody>`;
|
||
networkRows.forEach(nic => {
|
||
const macs = nic.mac_addresses ? nic.mac_addresses.join(', ') : '-';
|
||
const statusClass = nic.status === 'OK' ? '' : 'status-warning';
|
||
html += `<tr>
|
||
<td>${escapeHtml(nic.location || nic.slot || '-')}</td>
|
||
<td>${escapeHtml(nic.model || '-')}</td>
|
||
<td>${escapeHtml(nic.manufacturer || nic.vendor || '-')}</td>
|
||
<td>${nic.port_count || '-'}</td>
|
||
<td>${escapeHtml(nic.port_type || '-')}</td>
|
||
<td><code>${escapeHtml(macs)}</code></td>
|
||
<td class="${statusClass}">${escapeHtml(nic.status || '-')}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
} else {
|
||
html += '<p class="no-data">Нет данных о сетевых адаптерах</p>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// PCIe Device Inventory tab
|
||
html += '<div class="config-tab-content" id="config-pcie">';
|
||
if (inventoryRows.length > 0) {
|
||
html += '<h3>PCIe устройства</h3>';
|
||
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 += `<h4 class="pcie-group-title">${escapeHtml(group.title)} · занято линий: ${group.lanes}</h4>`;
|
||
html += '<table class="config-table"><thead><tr><th>Слот</th><th>BDF</th><th>Модель</th><th>Производитель</th><th>Vendor:Device ID</th><th>PCIe Link</th><th>Серийный номер</th><th>Прошивка</th></tr></thead><tbody>';
|
||
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 += `<tr>
|
||
<td>${escapeHtml(p.slot || '-')}</td>
|
||
<td><code>${escapeHtml(p.bdf || '-')}</code></td>
|
||
<td>${escapeHtml(p.model || p.part_number || p.device_class || '-')}</td>
|
||
<td>${escapeHtml(p.manufacturer || '-')}</td>
|
||
<td><code>${p.vendor_id ? p.vendor_id.toString(16) : '-'}:${p.device_id ? p.device_id.toString(16) : '-'}</code></td>
|
||
<td>${pcieLink}</td>
|
||
<td><code>${escapeHtml(p.serial_number || '-')}</code></td>
|
||
<td><code>${escapeHtml(firmware || '-')}</code></td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
});
|
||
} else {
|
||
html += '<p class="no-data">Нет данных о PCIe устройствах</p>';
|
||
}
|
||
html += '</div>';
|
||
|
||
container.innerHTML = html;
|
||
|
||
// Initialize config sub-tabs
|
||
initConfigTabs();
|
||
}
|
||
|
||
function initConfigTabs() {
|
||
const tabs = document.querySelectorAll('.config-tab');
|
||
tabs.forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
tabs.forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.config-tab-content').forEach(c => c.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
document.getElementById('config-' + tab.dataset.configTab).classList.add('active');
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadFirmware() {
|
||
try {
|
||
const response = await fetch('/api/firmware');
|
||
const firmware = await response.json();
|
||
renderFirmware(firmware);
|
||
} catch (err) {
|
||
console.error('Failed to load firmware:', err);
|
||
}
|
||
}
|
||
|
||
let allFirmware = [];
|
||
|
||
function renderFirmware(firmware) {
|
||
allFirmware = firmware || [];
|
||
|
||
// Render in Firmware tab
|
||
const tbody = document.querySelector('#firmware-table tbody');
|
||
tbody.innerHTML = '';
|
||
|
||
if (!firmware || firmware.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="3" class="no-data">Нет данных о прошивках</td></tr>';
|
||
} else {
|
||
firmware.forEach(fw => {
|
||
const row = document.createElement('tr');
|
||
row.innerHTML = `
|
||
<td>${escapeHtml(fw.component)}</td>
|
||
<td>${escapeHtml(fw.model)}</td>
|
||
<td><code>${escapeHtml(fw.version)}</code></td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
}
|
||
|
||
async function loadSensors() {
|
||
try {
|
||
const response = await fetch('/api/sensors');
|
||
allSensors = await response.json();
|
||
renderSensors(allSensors);
|
||
} catch (err) {
|
||
console.error('Failed to load sensors:', err);
|
||
}
|
||
}
|
||
|
||
function renderSensors(sensors) {
|
||
const container = document.getElementById('sensors-content');
|
||
|
||
if (!sensors || sensors.length === 0) {
|
||
container.innerHTML = '<p class="no-data">Нет данных о сенсорах</p>';
|
||
return;
|
||
}
|
||
|
||
// Group by type
|
||
const byType = {};
|
||
sensors.forEach(s => {
|
||
if (!byType[s.type]) byType[s.type] = [];
|
||
byType[s.type].push(s);
|
||
});
|
||
|
||
const typeNames = {
|
||
temperature: 'Температура',
|
||
voltage: 'Напряжение',
|
||
power: 'Мощность',
|
||
fan_speed: 'Вентиляторы',
|
||
fan_status: 'Статус вентиляторов',
|
||
psu_status: 'Статус БП',
|
||
cpu_status: 'Статус CPU',
|
||
storage_status: 'Статус накопителей',
|
||
other: 'Прочее'
|
||
};
|
||
|
||
let html = '';
|
||
for (const [type, items] of Object.entries(byType)) {
|
||
html += `<div class="sensor-group" data-type="${type}">
|
||
<h3>${typeNames[type] || type}</h3>
|
||
<div class="sensor-grid">`;
|
||
|
||
items.forEach(s => {
|
||
let valueStr = '';
|
||
let statusClass = s.status === 'ok' ? 'ok' : (s.status === 'ns' ? 'ns' : 'warn');
|
||
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 += `<div class="sensor-card ${statusClass}${extraClass}">
|
||
<span class="sensor-name">${escapeHtml(s.name)}</span>
|
||
<span class="sensor-value">${escapeHtml(valueStr)}</span>
|
||
</div>`;
|
||
});
|
||
|
||
html += '</div></div>';
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function filterSensors(type) {
|
||
if (!type) {
|
||
renderSensors(allSensors);
|
||
return;
|
||
}
|
||
const filtered = allSensors.filter(s => s.type === type);
|
||
renderSensors(filtered);
|
||
}
|
||
|
||
async function loadSerials() {
|
||
try {
|
||
const response = await fetch('/api/serials');
|
||
allSerials = await response.json();
|
||
renderSerials(allSerials);
|
||
} catch (err) {
|
||
console.error('Failed to load serials:', err);
|
||
}
|
||
}
|
||
|
||
function renderSerials(serials) {
|
||
const tbody = document.querySelector('#serials-table tbody');
|
||
tbody.innerHTML = '';
|
||
|
||
if (!serials || serials.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="no-data">Нет серийных номеров</td></tr>';
|
||
return;
|
||
}
|
||
|
||
const categoryNames = {
|
||
'Board': 'Мат. плата',
|
||
'CPU': 'Процессор',
|
||
'Memory': 'Память',
|
||
'Storage': 'Накопитель',
|
||
'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 = `
|
||
<td><span class="category-badge ${item.category.toLowerCase()}">${categoryNames[item.category] || item.category}</span></td>
|
||
<td>${escapeHtml(item.component)}</td>
|
||
<td>${escapeHtml(item.location || '-')}</td>
|
||
<td><code>${escapeHtml(item.serial_number)}</code></td>
|
||
<td>${escapeHtml(item.manufacturer || '-')}</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function filterSerials(category) {
|
||
if (!category) {
|
||
renderSerials(allSerials);
|
||
return;
|
||
}
|
||
const filtered = allSerials.filter(s => s.category === category);
|
||
renderSerials(filtered);
|
||
}
|
||
|
||
async function loadEvents() {
|
||
try {
|
||
const response = await fetch('/api/events');
|
||
allEvents = await response.json();
|
||
renderEvents(allEvents);
|
||
} catch (err) {
|
||
console.error('Failed to load events:', err);
|
||
}
|
||
}
|
||
|
||
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 = '<tr><td colspan="5" class="no-data">Ошибок разбора не обнаружено</td></tr>';
|
||
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 = `
|
||
<td>${escapeHtml(source)}</td>
|
||
<td>${escapeHtml(category)}</td>
|
||
<td><span class="severity ${escapeHtml(severity)}">${escapeHtml(severity)}</span></td>
|
||
<td><code>${escapeHtml(path)}</code></td>
|
||
<td>${escapeHtml(message)}</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function renderEvents(events) {
|
||
const tbody = document.querySelector('#events-table tbody');
|
||
tbody.innerHTML = '';
|
||
|
||
if (!events || events.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="4" class="no-data">Нет событий</td></tr>';
|
||
return;
|
||
}
|
||
|
||
events.forEach(event => {
|
||
const row = document.createElement('tr');
|
||
row.innerHTML = `
|
||
<td>${formatDate(event.timestamp)}</td>
|
||
<td>${escapeHtml(event.source)}</td>
|
||
<td>${escapeHtml(event.description)}</td>
|
||
<td><span class="severity ${event.severity}">${event.severity}</span></td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function filterEvents(severity) {
|
||
if (!severity) {
|
||
renderEvents(allEvents);
|
||
return;
|
||
}
|
||
const filtered = allEvents.filter(e => e.severity === severity);
|
||
renderEvents(filtered);
|
||
}
|
||
|
||
// Export functions
|
||
function exportData(format) {
|
||
window.location.href = `/api/export/${format}`;
|
||
}
|
||
|
||
// Clear data
|
||
async function clearData() {
|
||
try {
|
||
await fetch('/api/clear', { method: 'DELETE' });
|
||
document.getElementById('upload-section').classList.remove('hidden');
|
||
document.getElementById('data-section').classList.add('hidden');
|
||
document.getElementById('clear-btn').classList.add('hidden');
|
||
document.getElementById('upload-status').textContent = '';
|
||
allSensors = [];
|
||
allEvents = [];
|
||
allSerials = [];
|
||
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 = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;"><div style="text-align:center;"><h1>LOGPile</h1><p>Приложение завершено. Можете закрыть эту вкладку.</p></div></div>';
|
||
} catch (err) {
|
||
// Server shutdown, connection will fail
|
||
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;"><div style="text-align:center;"><h1>LOGPile</h1><p>Приложение завершено. Можете закрыть эту вкладку.</p></div></div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Utilities
|
||
function formatDate(isoString) {
|
||
if (!isoString) return '-';
|
||
const date = new Date(isoString);
|
||
return date.toLocaleString('ru-RU');
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function 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 `<span${degradedClass}>${curLinkStr}</span> <span class="pcie-max">/ ${maxLinkStr}</span>`;
|
||
} else if (maxLinkStr && maxLinkStr !== curLinkStr) {
|
||
return `${curLinkStr} <span class="pcie-max">/ ${maxLinkStr}</span>`;
|
||
} else {
|
||
return curLinkStr;
|
||
}
|
||
}
|