// 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 skipHungButton = document.getElementById('skip-hung-btn'); const connectButton = document.getElementById('api-connect-btn'); const collectButton = document.getElementById('api-collect-btn'); 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 (skipHungButton) { skipHungButton.addEventListener('click', () => { skipHungCollectionJob(); }); } if (connectButton) { connectButton.addEventListener('click', () => { startApiProbe(); }); } if (collectButton) { collectButton.addEventListener('click', () => { startCollectionWithOptions(); }); } 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 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 || 'Connection check failed'); } apiProbeResult = body; renderApiProbeState(); }) .catch((err) => { resetApiProbeState(); renderApiConnectStatus(false); const status = document.getElementById('api-connect-status'); if (status) { status.textContent = err.message || 'Connection check failed'; 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 = 'Run the connection check first.'; status.className = 'api-connect-status error'; } return; } const debugPayloads = document.getElementById('api-debug-payloads'); 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'); if (!connectButton || !probeOptions || !status) { return; } if (!apiProbeResult || !apiProbeResult.reachable) { status.textContent = 'Connection check did not pass.'; status.className = 'api-connect-status error'; probeOptions.classList.add('hidden'); connectButton.textContent = 'Connect'; return; } const hostOn = apiProbeResult.host_powered_on; if (hostOn) { status.textContent = apiProbeResult.message || 'Connected to the BMC. The host is powered on.'; status.className = 'api-connect-status success'; } else { status.textContent = apiProbeResult.message || 'Connected to the BMC. The host is powered off.'; status.className = 'api-connect-status warning'; } probeOptions.classList.remove('hidden'); const hostOffWarning = document.getElementById('api-host-off-warning'); if (hostOffWarning) { if (hostOn) { hostOffWarning.classList.add('hidden'); } else { hostOffWarning.classList.remove('hidden'); } } connectButton.textContent = 'Reconnect'; } function resetApiProbeState() { apiProbeResult = null; clearApiPowerDecisionTimer(); const connectButton = document.getElementById('api-connect-btn'); const probeOptions = document.getElementById('api-probe-options'); if (connectButton) { connectButton.textContent = 'Connect'; } 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 = 'Enter a host.'; } const port = Number(portRaw); const isPortInteger = Number.isInteger(port); if (!portRaw) { errors.port = 'Enter a port.'; } else if (!isPortInteger || port < 1 || port > 65535) { errors.port = 'Port must be between 1 and 65535.'; } if (!username) { errors.username = 'Enter a username.'; } if (!password) { errors.password = 'Enter a password.'; } if (Object.keys(errors).length > 0) { return { isValid: false, errors, payload: null }; } // TODO: restore the protocol selector when a real IPMI connector exists. 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 = `Fix the form errors:`; } function renderApiConnectStatus(isValid) { const status = document.getElementById('api-connect-status'); if (!status) { return; } if (!isValid) { status.textContent = 'The form was not submitted because it contains errors.'; status.className = 'api-connect-status error'; return; } status.textContent = 'Connecting...'; 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 || 'Failed to start the job'); } collectionJob = { id: body.job_id, status: normalizeJobStatus(body.status || 'queued'), progress: 0, currentPhase: '', etaSeconds: null, logs: [], activeModules: [], moduleScores: [], debugInfo: null, payload }; appendJobLog(body.message || 'Job queued'); 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 || 'Failed to start the job'; 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 || 'Failed to fetch job status'); } 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(`Error: ${collectionJob.error}`); renderCollectionJob(); } } else if (prevStatus !== collectionJob.status && collectionJob.status === 'running') { renderCollectionJob(); } }) .catch((err) => { appendJobLog(`Status error: ${err.message}`); renderCollectionJob(); clearCollectionJobPolling(); setApiFormBlocked(false); }); } function skipHungCollectionJob() { if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) { return; } const btn = document.getElementById('skip-hung-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Skipping...'; } fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}/skip`, { method: 'POST' }) .then(async (response) => { const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(body.error || 'Failed to skip hung requests'); } syncServerLogs(body.logs); renderCollectionJob(); }) .catch((err) => { appendJobLog(`Skip error: ${err.message}`); if (btn) { btn.disabled = false; btn.textContent = 'Skip Hung Requests'; } renderCollectionJob(); }); } 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 || 'Failed to cancel the job'); } collectionJob.status = normalizeJobStatus(body.status || 'canceled'); collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress; syncServerLogs(body.logs); clearCollectionJobPolling(); renderCollectionJob(); }) .catch((err) => { appendJobLog(`Cancel error: ${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('en-GB', { hour12: false }), message: parsed.message, hidden: true }); return; } collectionJob.logs.push({ id: ++collectionJobLogCounter, time: parsed.time || new Date().toLocaleTimeString('en-GB', { 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) { // Match the existing server-side snapshot progress format and collapse it to one path. let m = msg.match(/snapshot:\s+документов=\d+[^,]*,.*последний=(\S+)/i); if (m) { const path = m[1].replace(/^\.\.\./, '').replace(/^\/redfish\/v1/, '') || m[1]; return `Snapshot: ${path}`; } // Match the existing server-side snapshot completion format. m = msg.match(/snapshot:\s+собрано\s+(\d+)\s+документов/i); if (m) { return `Snapshot: ${m[1]} documents collected`; } // Match the existing server-side plan-B completion format. m = msg.match(/plan-B завершен за ([^(]+)\(targets=(\d+),\s*recovered=(\d+)\)/i); if (m) { const recovered = parseInt(m[3], 10); const suffix = recovered > 0 ? `, recovered ${m[3]}` : ''; return `Plan-B: completed in ${m[1].trim()}${suffix}`; } // Match the existing server-side prefetch progress format. m = msg.match(/prefetch критичных endpoint[^(]*\(([^)]+)\)/i); if (m) { return `Critical endpoint prefetch (${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: 'Collection completed', failed: 'Collection failed', canceled: 'Collection 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; const skipBtn = document.getElementById('skip-hung-btn'); if (skipBtn) { const isCollecting = !isTerminal && collectionJob.status === 'running'; if (isCollecting) { skipBtn.classList.remove('hidden'); } else { skipBtn.classList.add('hidden'); skipBtn.disabled = false; skipBtn.textContent = 'Skip Hung Requests'; } } setApiFormBlocked(!isTerminal); } function latestCollectionActivityMessage() { if (collectionJob && collectionJob.currentPhase) { return humanizeCollectionPhase(collectionJob.currentPhase); } if (!collectionJob || !Array.isArray(collectionJob.logs) || collectionJob.logs.length === 0) { return 'Collecting data...'; } const last = String(collectionJob.logs[collectionJob.logs.length - 1].message || '').trim(); if (!last) { return 'Collecting data...'; } // 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 'Collecting data...'; } return cleaned.replace(/\s*[,(]?\s*ETA[^,;)]*/i, '').trim() || 'Collecting data...'; } 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('en-GB', { 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 || 'Collecting data...'; } 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) { const parserNames = data.parsers.map((p) => { const name = escapeHtml(p.name || ''); const version = escapeHtml(p.version || ''); return version ? `${name} (v${version})` : name; }).filter(Boolean); container.innerHTML = `

    Parsers

    ${escapeHtml(String(parserNames.length))} loaded

    ${parserNames.join(' · ')}

    `; } } 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 = 'Uploading and analyzing...'; 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} sensors, ${result.stats.fru} components, ${result.stats.events} events`; status.className = 'success'; loadData(result.vendor, result.filename); } else { status.textContent = result.error || 'Upload failed'; status.className = 'error'; } } catch (err) { status.textContent = 'Connection error'; 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 = 'Checking duplicates...'; 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 = 'Choose a folder with files, including nested directories.'; 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 ? `Examples: ${previewFiles.join(', ')}` : ''; const skippedText = skippedCount > 0 ? ` Unsupported files skipped: ${skippedCount}.` : ''; const batchCount = Math.ceil(supportedFiles.length / CONVERT_MAX_FILES_PER_BATCH); const batchesText = batchCount > 1 ? ` ${batchCount} pass(es) of ${CONVERT_MAX_FILES_PER_BATCH} files will be required.` : ''; let dupText = ''; if (convertDuplicates.length > 0) { const names = convertDuplicates.map(d => escapeHtml(d.name)).join(', '); const reasons = convertDuplicates.map(d => d.reason === 'hash' ? 'same content' : 'same name'); const uniqueReasons = [...new Set(reasons)].join(', '); dupText = ` ⚠ Duplicates skipped: ${convertDuplicates.length} (${uniqueReasons}): ${names}.`; } summary.innerHTML = `${supportedFiles.length} files are ready for conversion.${previewText ? ` ${previewText}` : ''}${remaining > 0 ? ` and ${remaining} more` : ''}.${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('No files selected for conversion', '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('The selected folder does not contain any supported files', 'error'); return; } const batches = chunkFiles(supportedFiles, CONVERT_MAX_FILES_PER_BATCH); isConvertRunning = true; runButton.disabled = true; renderConvertProgress(0, 'Preparing upload...'); renderConvertStatus(`Running batch conversion (${batches.length} pass(es))...`, '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 ${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}: upload ${clamped}%`); }); if (!startResponse.ok) { const errorPayload = parseConvertErrorPayload(startResponse.bodyText); hideConvertProgress(); renderConvertStatus(`${passLabel}: ${errorPayload.error || 'batch conversion failed'}`, 'error'); return; } if (!startResponse.jobId) { hideConvertProgress(); renderConvertStatus(`${passLabel}: server did not return a job ID`, '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}: conversion ${serverProgress}%`); }); const downloadResponse = await downloadConvertArchive(startResponse.jobId); if (!downloadResponse.ok) { const errorPayload = parseConvertErrorPayload(downloadResponse.bodyText); hideConvertProgress(); renderConvertStatus(`${passLabel}: ${errorPayload.error || 'failed to download the result'}`, 'error'); return; } const suffix = batches.length > 1 ? `-part${pass}` : ''; downloadBlob(downloadResponse.blob, `logpile-convert-${timestamp}${suffix}.zip`); passSummaries.push(downloadResponse.summaryHeader || `${passLabel}: completed`); } hideConvertProgress(); renderConvertStatus(passSummaries.join(' | '), 'success'); } catch (err) { hideConvertProgress(); renderConvertStatus('Connection error during conversion', '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 || 'Failed to fetch conversion status'); } 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 || 'Conversion failed'); } 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 || 'Running...'; } 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); } let auditViewerNonce = 0; // Load data from API async function loadData(vendor, filename) { 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'); 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); } } // 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 = ''; 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('Restart the application? All loaded data will be lost.')) { fetch('/api/clear', { method: 'DELETE' }).then(() => { window.location.reload(); }); } } // Exit app (shutdown server) async function exitApp() { if (confirm('Shut down the application?')) { try { await fetch('/api/shutdown', { method: 'POST' }); document.body.innerHTML = '

    LOGPile

    The application has stopped. You can close this tab.

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

    LOGPile

    The application has stopped. You can close this tab.

    '; } } } // Utilities function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }