Parse inventory_volume.log: Intel VROC (VMD) RAID volumes including RAID level, capacity (GiB/TiB support added), status and member drives. Add Drives []string to StorageVolume model. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1505 lines
52 KiB
JavaScript
1505 lines
52 KiB
JavaScript
// 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 = `<strong>Fix the form errors:</strong><ul>${messages.map(msg => `<li>${escapeHtml(msg)}</li>`).join('')}</ul>`;
|
|
}
|
|
|
|
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) => (
|
|
`<li><span class="log-time">${escapeHtml(log.time)}</span><span class="log-message">${escapeHtml(log.message)}</span></li>`
|
|
)).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 `<span class="job-module-chip" title="${escapeHtml(moduleTitle(module))}">
|
|
<span class="job-module-chip-name">${escapeHtml(module.name || '-')}</span>
|
|
<span class="job-module-chip-score">${escapeHtml(String(score))}</span>
|
|
</span>`;
|
|
}).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=<strong>${escapeHtml(throttled)}</strong>, snapshot_workers=<strong>${escapeHtml(String(debug.snapshot_workers || 0))}</strong>, prefetch_workers=<strong>${escapeHtml(String(debug.prefetch_workers || 0))}</strong>, prefetch_enabled=<strong>${escapeHtml(prefetchEnabled)}</strong>`;
|
|
|
|
const phases = Array.isArray(debug.phase_telemetry) ? debug.phase_telemetry : [];
|
|
if (phases.length === 0) {
|
|
phaseTelemetryNode.innerHTML = '';
|
|
return;
|
|
}
|
|
phaseTelemetryNode.innerHTML = phases.map((item) => (
|
|
`<div class="job-phase-row">
|
|
<span class="job-phase-name">${escapeHtml(humanizeCollectionPhase(item.phase || ''))}</span>
|
|
<span class="job-phase-metric">req=${escapeHtml(String(item.requests || 0))}</span>
|
|
<span class="job-phase-metric">err=${escapeHtml(String(item.errors || 0))}</span>
|
|
<span class="job-phase-metric">avg=${escapeHtml(String(item.avg_ms || 0))}ms</span>
|
|
<span class="job-phase-metric">p95=${escapeHtml(String(item.p95_ms || 0))}ms</span>
|
|
</div>`
|
|
)).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 = `
|
|
<p class="parsers-title">Parsers</p>
|
|
<p class="parsers-summary">${escapeHtml(String(parserNames.length))} loaded</p>
|
|
<p class="parsers-text">${parserNames.join(' · ')}</p>
|
|
`;
|
|
}
|
|
} 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 = `<strong>${escapeHtml(result.vendor)}</strong><br>` +
|
|
`${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 = ` <span style="color:#c0392b">⚠ Duplicates skipped: ${convertDuplicates.length} (${uniqueReasons}): ${names}.</span>`;
|
|
}
|
|
summary.innerHTML = `<strong>${supportedFiles.length}</strong> 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 = '<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>The application has stopped. You can close this tab.</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>The application has stopped. You can close this tab.</p></div></div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Utilities
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|