Add convert mode batch workflow with full progress
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user