Files
logpile/web/static/js/app.js
T
Mikhail ChusavitinandClaude Sonnet 5 95f552a0e5 feat(privacy): fold sanitize into the header "Remove personal data" button
Drops the in-panel sanitize sub-block (button + preview table + "already
sanitized" note) that duplicated affordances across the page. The header
"Clear Data" button is renamed "Remove personal data" and now sanitizes the
uploaded file and downloads the clean copy in one click - no preview, no
confirmation. It is shown only when the source can be sanitized, and reads
"Personal data removed" (disabled) when the file already looks de-identified.
The old clearData() teardown is covered by Restart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 10:19:44 +03:00

1672 lines
58 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('header-raw-btn').classList.remove('hidden');
document.getElementById('header-reanimator-btn').classList.remove('hidden');
document.getElementById('header-logs-csv-btn').classList.remove('hidden');
document.getElementById('header-log-meta').classList.remove('hidden');
loadAuditViewer();
loadParseErrors();
loadPrivacyScan();
}
async function loadPrivacyScan() {
const section = document.getElementById('privacy-section');
const rows = document.getElementById('privacy-rows');
const title = document.getElementById('privacy-title');
const customer = document.getElementById('privacy-customer');
if (!section || !rows) return;
const clearBtn = document.getElementById('clear-btn');
let data;
try {
const resp = await fetch('/api/privacy-scan');
if (!resp.ok) return;
data = await resp.json();
} catch (e) {
return;
}
if (!data || data.loaded === false) {
section.classList.add('hidden');
if (clearBtn) clearBtn.classList.add('hidden');
return;
}
const findings = Array.isArray(data.findings) ? data.findings : [];
const customers = Array.isArray(data.customers) ? data.customers : [];
const san = data.sanitized || null;
const alreadySanitized = !!(san && san.detected);
if (clearBtn) {
clearBtn.classList.toggle('hidden', !data.sanitizable);
clearBtn.disabled = alreadySanitized;
clearBtn.textContent = alreadySanitized ? 'Personal data removed' : 'Remove personal data';
}
if (findings.length === 0 && customers.length === 0 && !alreadySanitized) {
section.classList.add('hidden');
return;
}
const s = data.summary || {};
const parts = [];
if (s.high) parts.push(`${s.high} high`);
if (s.medium) parts.push(`${s.medium} medium`);
if (s.low) parts.push(`${s.low} low`);
const lead = alreadySanitized ? 'файл уже обезличен'
: (customers.length > 0 ? `likely ${customers[0].domain}` : `${findings.length} finding${findings.length > 1 ? 's' : ''}`);
title.textContent = `Customer data — ${lead}${parts.length ? ' · ' + parts.join(', ') : ''}`;
if (customers.length > 0) {
customer.innerHTML = customers.map(c =>
`<div class="privacy-customer-row"><strong>${escapeHtml(c.domain)}</strong> ` +
`<span class="privacy-conf privacy-conf-${escapeHtml(c.confidence || '')}">${escapeHtml(c.confidence || '')}</span> ` +
`<span class="privacy-hits">${c.hits || 0} hit${(c.hits || 0) === 1 ? '' : 's'}</span>` +
((c.evidence && c.evidence.length)
? `<ul class="privacy-evidence">${c.evidence.map(e => `<li>${escapeHtml(e)}</li>`).join('')}</ul>`
: '') +
`</div>`
).join('');
customer.classList.remove('hidden');
} else {
customer.innerHTML = '';
customer.classList.add('hidden');
}
rows.innerHTML = '';
for (const f of findings) {
const loc = f.line ? `${f.path}:${f.line}` : (f.path || '');
const tr = document.createElement('tr');
tr.className = `parse-error-row parse-error-${f.severity === 'high' ? 'error' : (f.severity === 'medium' ? 'warning' : 'info')}`;
tr.innerHTML =
`<td>${escapeHtml(f.severity || '')}</td>` +
`<td>${escapeHtml(f.category || '')}</td>` +
`<td>${escapeHtml(loc)}</td>` +
`<td>${escapeHtml(f.match || '')}</td>` +
`<td>${escapeHtml(f.hint || '')}</td>`;
rows.appendChild(tr);
}
const findingsBox = document.getElementById('privacy-findings');
const toggle = document.getElementById('privacy-toggle');
if (findings.length === 0) {
if (findingsBox) findingsBox.style.display = 'none';
if (toggle) toggle.style.visibility = 'hidden';
} else {
if (toggle) {
toggle.style.visibility = '';
toggle.textContent = privacyCollapsed ? '▼' : '▲';
}
if (findingsBox) findingsBox.style.display = privacyCollapsed ? 'none' : '';
}
section.classList.remove('hidden');
}
// Header "Remove personal data": sanitize the uploaded file and download the
// clean copy in one click - no preview, no confirmation.
async function removePersonalData() {
const btn = document.getElementById('clear-btn');
if (!btn || btn.disabled) return;
const label = btn.textContent;
btn.disabled = true;
btn.textContent = 'Working…';
try {
const resp = await fetch('/api/sanitize', { method: 'POST' });
if (!resp.ok) {
const d = await resp.json().catch(() => ({}));
throw new Error(d.error || ('HTTP ' + resp.status));
}
window.location = '/api/sanitize/download';
setTimeout(() => { btn.disabled = false; btn.textContent = label; }, 2000);
} catch (e) {
console.error('remove personal data failed:', e);
btn.textContent = 'Failed';
setTimeout(() => { btn.disabled = false; btn.textContent = label; }, 2500);
}
}
// The header + customer summary stay visible; only the findings table collapses.
let privacyCollapsed = true;
function togglePrivacy() {
const box = document.getElementById('privacy-findings');
const toggle = document.getElementById('privacy-toggle');
if (!box || !document.getElementById('privacy-rows').children.length) return;
privacyCollapsed = !privacyCollapsed;
box.style.display = privacyCollapsed ? 'none' : '';
toggle.textContent = privacyCollapsed ? '▼' : '▲';
}
async function loadParseErrors() {
const section = document.getElementById('parse-errors-section');
const rows = document.getElementById('parse-errors-rows');
const title = document.getElementById('parse-errors-title');
if (!section || !rows) return;
let data;
try {
const resp = await fetch('/api/parse-errors');
if (!resp.ok) return;
data = await resp.json();
} catch (e) {
return;
}
const items = (data && data.items) ? data.items : [];
if (items.length === 0) {
section.classList.add('hidden');
return;
}
const errorCount = items.filter(i => i.severity === 'error').length;
const warnCount = items.filter(i => i.severity === 'warning').length;
const parts = [];
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? 's' : ''}`);
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? 's' : ''}`);
const otherCount = items.length - errorCount - warnCount;
if (otherCount > 0) parts.push(`${otherCount} notice${otherCount > 1 ? 's' : ''}`);
title.textContent = `Collection diagnostics — ${parts.join(', ')}`;
rows.innerHTML = '';
for (const item of items) {
const tr = document.createElement('tr');
tr.className = `parse-error-row parse-error-${item.severity || 'info'}`;
tr.innerHTML =
`<td>${escapeHtml(item.source || '')}</td>` +
`<td>${escapeHtml(item.path || item.category || '')}</td>` +
`<td>${escapeHtml(item.message || '')}</td>` +
`<td>${escapeHtml(item.detail || '')}</td>`;
rows.appendChild(tr);
}
section.classList.remove('hidden');
}
let parseErrorsCollapsed = false;
function toggleParseErrors() {
const body = document.getElementById('parse-errors-body');
const toggle = document.getElementById('parse-errors-toggle');
if (!body) return;
parseErrorsCollapsed = !parseErrorsCollapsed;
body.style.display = parseErrorsCollapsed ? 'none' : '';
toggle.textContent = parseErrorsCollapsed ? '▼' : '▲';
}
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}`;
}
// 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;
}