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

@@ -84,13 +84,6 @@ main {
}
.upload-area button {
background: #3498db;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
margin: 1rem 0;
}
@@ -166,6 +159,9 @@ main {
.api-form-actions {
margin-top: 0.9rem;
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
#api-connect-form.is-disabled {
@@ -173,19 +169,39 @@ main {
pointer-events: none;
}
#api-connect-btn {
#api-connect-btn,
#convert-folder-btn,
#convert-run-btn,
#cancel-job-btn,
.upload-area button {
background: #3498db;
color: white;
color: #fff;
border: none;
padding: 0.6rem 1.2rem;
border-radius: 4px;
border-radius: 6px;
padding: 0.6rem 1rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, opacity 0.2s ease;
}
#api-connect-btn:hover {
#api-connect-btn:hover,
#convert-folder-btn:hover,
#convert-run-btn:hover,
#cancel-job-btn:hover,
.upload-area button:hover {
background: #2980b9;
}
#convert-run-btn:disabled,
#convert-folder-btn:disabled,
#api-connect-btn:disabled,
#cancel-job-btn:disabled,
.upload-area button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.api-connect-status {
margin-top: 0.75rem;
font-size: 0.85rem;
@@ -199,6 +215,38 @@ main {
color: #dc3545;
}
.api-connect-status.info {
color: #0f4dba;
}
.convert-progress {
margin-top: 0.9rem;
}
.convert-progress-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
font-size: 0.82rem;
color: #475569;
}
.convert-progress-track {
height: 12px;
border-radius: 999px;
border: 1px solid #cbd5e1;
background: #e2e8f0;
overflow: hidden;
}
.convert-progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #2563eb, #0ea5e9);
transition: width 0.2s ease;
}
.job-status {
margin-top: 1rem;
border: 1px solid #d0d7de;
@@ -220,15 +268,6 @@ main {
color: #2c3e50;
}
#cancel-job-btn {
background: #dc3545;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.45rem 0.75rem;
cursor: pointer;
}
#cancel-job-btn:disabled {
background: #9ca3af;
cursor: default;

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');

View File

@@ -17,6 +17,7 @@
<div class="source-switch" role="tablist" aria-label="Источник данных">
<button type="button" class="source-switch-btn active" data-source-type="archive">Архив</button>
<button type="button" class="source-switch-btn" data-source-type="api">API</button>
<button type="button" class="source-switch-btn" data-source-type="convert">Convert</button>
</div>
<div id="archive-source-content">
@@ -90,6 +91,27 @@
</div>
</section>
</div>
<div id="convert-source-content" class="api-placeholder hidden">
<h3>Пакетная выгрузка Reanimator</h3>
<p>Выберите папку с файлами поддерживаемого типа. Для каждого файла будет создан отдельный экспорт Reanimator.</p>
<div class="api-form-actions">
<input type="file" id="convert-folder-input" webkitdirectory directory multiple hidden>
<button id="convert-folder-btn" type="button" onclick="document.getElementById('convert-folder-input').click()">Выбрать папку</button>
<button id="convert-run-btn" type="button">Конвертировать в Reanimator</button>
</div>
<div id="convert-progress" class="convert-progress hidden" aria-live="polite">
<div class="convert-progress-meta">
<span id="convert-progress-label">Подготовка...</span>
<span id="convert-progress-value">0%</span>
</div>
<div class="convert-progress-track">
<div id="convert-progress-bar" class="convert-progress-bar" style="width: 0%"></div>
</div>
</div>
<div id="convert-folder-summary" class="api-connect-status"></div>
<div id="convert-status" class="api-connect-status"></div>
</div>
</section>
<section id="data-section" class="hidden">