Add convert mode batch workflow with full progress

This commit is contained in:
2026-02-28 21:44:36 +03:00
parent bb4505a249
commit 25e3b8bb42
5 changed files with 823 additions and 84 deletions

View File

@@ -4,12 +4,15 @@ document.addEventListener('DOMContentLoaded', () => {
initSourceType();
initApiSource();
initUpload();
initConvertMode();
initTabs();
initFilters();
loadParsersInfo();
});
let sourceType = 'archive';
let convertFiles = [];
let isConvertRunning = false;
let apiConnectPayload = null;
let collectionJob = null;
let collectionJobPollTimer = null;
@@ -29,7 +32,13 @@ function initSourceType() {
}
function setSourceType(nextType) {
sourceType = nextType === 'api' ? 'api' : 'archive';
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);
@@ -37,8 +46,12 @@ function setSourceType(nextType) {
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() {
@@ -599,6 +612,295 @@ async function uploadFile(file) {
}
}
function initConvertMode() {
const folderInput = document.getElementById('convert-folder-input');
const runButton = document.getElementById('convert-run-btn');
if (!folderInput || !runButton) {
return;
}
folderInput.addEventListener('change', () => {
convertFiles = Array.from(folderInput.files || []).filter(file => file && file.name);
renderConvertSummary();
});
runButton.addEventListener('click', async () => {
await runConvertBatch();
});
renderConvertSummary();
}
function renderConvertSummary() {
const summary = document.getElementById('convert-folder-summary');
if (!summary) {
return;
}
if (convertFiles.length === 0) {
summary.textContent = 'Выберите папку с файлами, включая вложенные каталоги.';
summary.className = 'api-connect-status';
return;
}
const supportedFiles = convertFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name));
const skippedCount = convertFiles.length - supportedFiles.length;
const previewCount = 5;
const previewFiles = supportedFiles.slice(0, previewCount).map(file => escapeHtml(file.webkitRelativePath || file.name));
const remaining = supportedFiles.length - previewFiles.length;
const previewText = previewFiles.length > 0 ? `Примеры: ${previewFiles.join(', ')}` : '';
const skippedText = skippedCount > 0 ? ` Пропущено неподдерживаемых: ${skippedCount}.` : '';
summary.innerHTML = `<strong>${supportedFiles.length}</strong> файлов готовы к конвертации.${previewText ? ` ${previewText}` : ''}${remaining > 0 ? ` и ещё ${remaining}` : ''}.${skippedText}`;
summary.className = 'api-connect-status';
}
async function runConvertBatch() {
const runButton = document.getElementById('convert-run-btn');
if (!runButton || isConvertRunning) {
return;
}
if (convertFiles.length === 0) {
renderConvertStatus('Нет файлов для конвертации', 'error');
return;
}
const supportedFiles = convertFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name));
if (supportedFiles.length === 0) {
renderConvertStatus('В выбранной папке нет файлов поддерживаемого типа', 'error');
return;
}
isConvertRunning = true;
runButton.disabled = true;
renderConvertProgress(0, 'Подготовка загрузки...');
renderConvertStatus('Выполняю пакетную конвертацию...', 'info');
const formData = new FormData();
supportedFiles.forEach(file => {
const relativePath = file.webkitRelativePath || file.name || 'file';
formData.append('files[]', file, relativePath);
});
try {
const startResponse = await uploadConvertBatch(formData, (percent) => {
const uploadPercent = Math.round(percent * 0.3);
renderConvertProgress(uploadPercent, `Загрузка файлов: ${percent}%`);
});
if (!startResponse.ok) {
const errorPayload = parseConvertErrorPayload(startResponse.bodyText);
hideConvertProgress();
renderConvertStatus(errorPayload.error || 'Пакетная конвертация завершилась с ошибкой', 'error');
return;
}
if (!startResponse.jobId) {
hideConvertProgress();
renderConvertStatus('Сервер не вернул идентификатор задачи', 'error');
return;
}
await waitForConvertJob(startResponse.jobId, (statusPayload) => {
const serverProgress = Number(statusPayload.progress || 0);
const combined = 30 + Math.round(Math.max(0, Math.min(100, serverProgress)) * 0.7);
renderConvertProgress(combined, `Конвертация: ${serverProgress}%`);
});
renderConvertProgress(100, 'Подготовка выгрузки...');
const downloadResponse = await downloadConvertArchive(startResponse.jobId);
if (!downloadResponse.ok) {
const errorPayload = parseConvertErrorPayload(downloadResponse.bodyText);
hideConvertProgress();
renderConvertStatus(errorPayload.error || 'Не удалось скачать результат', 'error');
return;
}
const blob = downloadResponse.blob;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
downloadBlob(blob, `logpile-convert-${timestamp}.zip`);
const summary = downloadResponse.summaryHeader || 'Конвертация завершена';
hideConvertProgress();
renderConvertStatus(summary, 'success');
} catch (err) {
hideConvertProgress();
renderConvertStatus('Ошибка соединения при конвертации', 'error');
} finally {
isConvertRunning = false;
runButton.disabled = false;
}
}
function uploadConvertBatch(formData, onUploadPercent) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/convert');
xhr.responseType = 'text';
xhr.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable) {
return;
}
const percent = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)));
onUploadPercent(percent);
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
let body = {};
try {
body = JSON.parse(xhr.responseText || '{}');
} catch (err) {
body = {};
}
resolve({
ok: true,
status: xhr.status,
jobId: body.job_id || ''
});
return;
}
resolve({
ok: false,
status: xhr.status,
bodyText: xhr.responseText || ''
});
});
xhr.addEventListener('error', () => {
reject(new Error('network'));
});
xhr.send(formData);
});
}
async function waitForConvertJob(jobId, onProgress) {
while (true) {
const response = await fetch(`/api/convert/${encodeURIComponent(jobId)}`);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || 'Не удалось получить статус конвертации');
}
if (onProgress) {
onProgress(payload);
}
const status = String(payload.status || '').toLowerCase();
if (status === 'success') {
return payload;
}
if (status === 'failed' || status === 'canceled') {
throw new Error(payload.error || 'Конвертация завершилась ошибкой');
}
await delay(900);
}
}
async function downloadConvertArchive(jobId) {
const response = await fetch(`/api/convert/${encodeURIComponent(jobId)}/download`);
if (!response.ok) {
return {
ok: false,
bodyText: await response.text().catch(() => '')
};
}
return {
ok: true,
blob: await response.blob(),
summaryHeader: response.headers.get('X-Convert-Summary') || ''
};
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function parseConvertErrorPayload(bodyText) {
if (!bodyText) {
return {};
}
try {
return JSON.parse(bodyText);
} catch (err) {
return {};
}
}
function isSupportedConvertFileName(filename) {
const name = String(filename || '').trim().toLowerCase();
if (!name) {
return false;
}
return (
name.endsWith('.zip') ||
name.endsWith('.tar') ||
name.endsWith('.tar.gz') ||
name.endsWith('.tgz') ||
name.endsWith('.json') ||
name.endsWith('.txt') ||
name.endsWith('.log')
);
}
function renderConvertStatus(message, status) {
const statusNode = document.getElementById('convert-status');
if (!statusNode) {
return;
}
statusNode.textContent = message || '';
statusNode.className = 'api-connect-status';
if (status === 'success') {
statusNode.classList.add('success');
} else if (status === 'error') {
statusNode.classList.add('error');
} else if (status === 'info') {
statusNode.classList.add('info');
}
}
function renderConvertProgress(percent, label) {
const wrap = document.getElementById('convert-progress');
const bar = document.getElementById('convert-progress-bar');
const value = document.getElementById('convert-progress-value');
const text = document.getElementById('convert-progress-label');
if (!wrap || !bar || !value || !text) {
return;
}
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
wrap.classList.remove('hidden');
bar.style.width = `${safePercent}%`;
value.textContent = `${safePercent}%`;
text.textContent = label || 'Выполняется...';
}
function hideConvertProgress() {
const wrap = document.getElementById('convert-progress');
if (!wrap) {
return;
}
wrap.classList.add('hidden');
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.style.display = 'none';
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => {
URL.revokeObjectURL(url);
}, 3000);
}
// Tab navigation
function initTabs() {
const tabs = document.querySelectorAll('.tab');