v0.0.63: TTL-фикс, прерывание с сохранением, трекинг файлов/чанков, ETA (global+per-file), UI-таблица 3 секции + кнопка Прервать
Deploy drhider / validate (push) Canceled after 0s
Deploy drhider / validate (push) Canceled after 0s
This commit is contained in:
+212
-29
@@ -62,6 +62,12 @@
|
||||
.row-over td { background: rgba(220,38,38,.10) !important; color:#c0392b; }
|
||||
.row-over .num-cell { color:#c0392b; }
|
||||
.row-over .name-cell { color:#c0392b; }
|
||||
.row-current td { background: rgba(37,99,235,.12) !important; color:#1d4ed8; font-weight: 600; }
|
||||
.grp-row td {
|
||||
background: var(--brand-gray); font-size: 12px; font-weight: 700;
|
||||
letter-spacing: .03em; text-transform: uppercase; color: var(--muted);
|
||||
padding: 4px 8px; border: none;
|
||||
}
|
||||
.remove-btn {
|
||||
cursor: pointer; color: #f87171; background: none; border: none;
|
||||
padding: 2px 4px; font-size: 14px; line-height: 1;
|
||||
@@ -115,6 +121,7 @@
|
||||
font-size: 18px; font-weight: 700; width: fit-content;
|
||||
}
|
||||
.live-llm.show { display: inline-block; }
|
||||
.live-eta { margin-top: 8px; font-size: 14px; color: #1d4ed8; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -153,6 +160,7 @@
|
||||
</div>
|
||||
<div class="footer-bar">
|
||||
<span id="fileCount">0 файлов</span>
|
||||
<button class="btn" id="cancelBtn" style="display:none" onclick="confirmCancel()">⏹ Прервать</button>
|
||||
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
@@ -165,6 +173,7 @@
|
||||
<div class="live-file" id="liveFile">—</div>
|
||||
<div class="live-timer" id="liveTimer">0.0 с</div>
|
||||
<div class="live-llm" id="liveLlm">🤖 ИИ обрабатывает… <span id="liveLlmTime">0.0 с</span></div>
|
||||
<div class="live-eta" id="liveEta"></div>
|
||||
</div>
|
||||
<div class="stats-block" id="statsBlock">
|
||||
<div class="stats-title">📊 Итоги обработки</div>
|
||||
@@ -196,6 +205,13 @@ const db = document.getElementById('dlBtns');
|
||||
let sf = [];
|
||||
let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов
|
||||
let overNames = new Set(); // имена файлов сверх лимита (не участвуют в обфускации)
|
||||
// ── Состояние обработки (3-секционная таблица: готово / текущий / ожидают) ──
|
||||
let procPhase = 'idle'; // 'idle' | 'processing'
|
||||
let procState = {}; // sfIdx -> {st, elapsed, eta, est, chars, t0}
|
||||
let procNameIdx = {}; // имя (с бэка) -> sfIdx
|
||||
let procExtractDone = false; // true после extract_done (далее done = реальная готовность)
|
||||
let procRefresh = null; // setInterval перерисовки таблицы
|
||||
let ptimer = null, liveRefresh = null; // таймеры статуса и live-блока
|
||||
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл
|
||||
const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
|
||||
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
|
||||
@@ -208,10 +224,18 @@ let activeXHR = null; // активный XHR
|
||||
function resetAll() {
|
||||
if (activeES) { activeES.close(); activeES = null; }
|
||||
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
|
||||
if (ptimer) { clearInterval(ptimer); ptimer = null; }
|
||||
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
|
||||
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
|
||||
currentSid = '';
|
||||
sf = [];
|
||||
fileMeta = new Map();
|
||||
overNames = new Set();
|
||||
procPhase = 'idle';
|
||||
procState = {};
|
||||
procNameIdx = {};
|
||||
procExtractDone = false;
|
||||
document.getElementById('cancelBtn').style.display = 'none';
|
||||
fi.value = '';
|
||||
rr();
|
||||
st.className = '';
|
||||
@@ -227,7 +251,13 @@ window.addEventListener('beforeunload', () => resetAll());
|
||||
|
||||
function fs(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
|
||||
|
||||
function fmtSec(s) {
|
||||
s = Math.max(0, Math.round(s));
|
||||
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
|
||||
}
|
||||
|
||||
function rr() {
|
||||
if (procPhase === 'processing') { renderProcTable(); return; }
|
||||
if (sf.length === 0) { fl.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; }
|
||||
else {
|
||||
fl.innerHTML = sf.map((f, i) => {
|
||||
@@ -244,6 +274,91 @@ function rr() {
|
||||
ub.disabled = cntMain === 0;
|
||||
}
|
||||
|
||||
// ═══ 3-секционная таблица во время обработки (готово / текущий / ожидают) ═══
|
||||
function procRow(i, stTxt) {
|
||||
const f = sf[i];
|
||||
const over = overNames.has(f.name);
|
||||
const cls = (procState[i] && procState[i].st === 'current') ? ' class="row-current"'
|
||||
: (over ? ' class="row-over"' : '');
|
||||
return '<tr' + cls + '><td class="name-cell">' + f.name + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
|
||||
}
|
||||
|
||||
function renderProcTable() {
|
||||
const groups = { done: [], current: [], pending: [] };
|
||||
for (let i = 0; i < sf.length; i++) {
|
||||
const st = procState[i] ? procState[i].st : 'pending';
|
||||
if (st === 'done' || st === 'skipped') groups.done.push(i);
|
||||
else if (st === 'current') groups.current.push(i);
|
||||
else groups.pending.push(i);
|
||||
}
|
||||
// Оценка скорости из текущего файла (сек/символ) — для «ожидающих»
|
||||
let rate = null;
|
||||
for (const i of groups.current) {
|
||||
const p = procState[i];
|
||||
const cur = (performance.now() - p.t0) / 1000;
|
||||
const total = cur + (p.eta != null ? p.eta : 0);
|
||||
if (p.chars > 0 && total > 0) rate = total / p.chars;
|
||||
}
|
||||
const rows = [];
|
||||
if (groups.done.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">✓ Обработанные (' + groups.done.length + ')</td></tr>');
|
||||
for (const i of groups.done) {
|
||||
const p = procState[i];
|
||||
const txt = p.st === 'skipped'
|
||||
? '<span style="color:#c0392b;">пропущен</span>'
|
||||
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
|
||||
rows.push(procRow(i, txt));
|
||||
}
|
||||
}
|
||||
if (groups.current.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">▶ Текущий файл</td></tr>');
|
||||
for (const i of groups.current) {
|
||||
const p = procState[i];
|
||||
const cur = ((performance.now() - p.t0) / 1000).toFixed(1);
|
||||
const eta = (p.eta != null) ? ' / ~' + fmtSec(p.eta) : '';
|
||||
rows.push(procRow(i, '<span style="color:#2563eb;">⏳ ' + cur + 'с' + eta + '</span>'));
|
||||
}
|
||||
}
|
||||
if (groups.pending.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">○ Ожидают обработки (' + groups.pending.length + ')</td></tr>');
|
||||
for (const i of groups.pending) {
|
||||
const p = procState[i] || {};
|
||||
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
|
||||
let txt;
|
||||
if (overNames.has(sf[i].name)) txt = '<span style="color:#c0392b;">🔥 не учитывается</span>';
|
||||
else if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
|
||||
else if (p.est != null) txt = '<span style="color:#999;">~' + fmtSec(p.est) + '</span>';
|
||||
else txt = '<span style="color:#999;">—</span>';
|
||||
rows.push(procRow(i, txt));
|
||||
}
|
||||
}
|
||||
fl.innerHTML = rows.join('');
|
||||
const cntDone = groups.done.length;
|
||||
fc.textContent = 'Готово ' + cntDone + ' / ' + sf.length + ' файлов';
|
||||
}
|
||||
|
||||
function confirmCancel() {
|
||||
document.getElementById('cancelModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function doCancel() {
|
||||
document.getElementById('cancelModal').style.display = 'none';
|
||||
if (!currentSid) return;
|
||||
fetch('/api/cancel/' + currentSid, { method: 'POST' }).catch(() => {});
|
||||
st.textContent = '⏹ Остановка… завершаем текущий файл';
|
||||
}
|
||||
|
||||
function finishProcUI() {
|
||||
if (activeES) { activeES.close(); activeES = null; }
|
||||
if (ptimer) { clearInterval(ptimer); ptimer = null; }
|
||||
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
|
||||
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
|
||||
document.getElementById('liveBlock').classList.remove('show');
|
||||
document.getElementById('cancelBtn').style.display = 'none';
|
||||
procPhase = 'idle';
|
||||
rr();
|
||||
}
|
||||
|
||||
function rm(i) { const nm = sf[i].name; overNames.delete(nm); fileMeta.delete(nm); sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); }
|
||||
|
||||
window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
|
||||
@@ -524,12 +639,24 @@ async function uploadFiles() {
|
||||
// Фаза 2: обработка (SSE — прогресс по каждому файлу)
|
||||
// Сброс загрузочных статусов — теперь этап обработки (только отправленные)
|
||||
for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>');
|
||||
// Инициализация 3-секционной таблицы (готово / текущий / ожидают)
|
||||
procPhase = 'processing';
|
||||
procState = {};
|
||||
procNameIdx = {};
|
||||
procExtractDone = false;
|
||||
for (let k = 0; k < total; k++) {
|
||||
procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 };
|
||||
}
|
||||
document.getElementById('cancelBtn').style.display = 'inline-block';
|
||||
if (procRefresh) clearInterval(procRefresh);
|
||||
procRefresh = setInterval(rr, 1000); // тикающий рендер (elapsed текущего файла)
|
||||
rr();
|
||||
const fileTimers = {}; // idx -> performance.now()
|
||||
const fileIntervals = {}; // idx -> setInterval id
|
||||
st.className = 'status progress';
|
||||
st.textContent = 'Обработка (этап 2/2)…';
|
||||
const t0 = performance.now();
|
||||
const ptimer = setInterval(() => {
|
||||
ptimer = setInterval(() => {
|
||||
const sec = Math.round((performance.now() - t0) / 1000);
|
||||
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
|
||||
}, 1000);
|
||||
@@ -540,12 +667,14 @@ async function uploadFiles() {
|
||||
const liveFileEl = document.getElementById('liveFile');
|
||||
const liveLlm = document.getElementById('liveLlm');
|
||||
const liveLlmTime = document.getElementById('liveLlmTime');
|
||||
const liveEta = document.getElementById('liveEta');
|
||||
let currentLiveFile = ''; // последнее имя файла из start
|
||||
liveLlm.classList.remove('show');
|
||||
liveEta.textContent = '';
|
||||
liveTimerEl.textContent = '0.0 с';
|
||||
liveFileEl.textContent = 'Подготовка…';
|
||||
liveBlock.classList.add('show');
|
||||
const liveRefresh = setInterval(() => {
|
||||
liveRefresh = setInterval(() => {
|
||||
const sec = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
liveTimerEl.textContent = sec + ' с';
|
||||
}, 200);
|
||||
@@ -556,29 +685,53 @@ async function uploadFiles() {
|
||||
activeES.addEventListener('start', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса
|
||||
// Таймер тикает ТОЛЬКО у текущего файла; таймеры остальных останавливаем
|
||||
for (const k in fileIntervals) {
|
||||
clearInterval(fileIntervals[k]);
|
||||
delete fileIntervals[k];
|
||||
}
|
||||
fileTimers[idx] = performance.now();
|
||||
fileIntervals[idx] = setInterval(function() {
|
||||
const elapsed = ((performance.now() - fileTimers[idx]) / 1000).toFixed(1);
|
||||
ss(idx, '<span style="color:#2563eb">⏳ ' + elapsed + 'с</span>');
|
||||
}, 200);
|
||||
// Показываем текущий файл и его размер в live-блоке
|
||||
procNameIdx[d.name] = idx;
|
||||
const p = procState[idx];
|
||||
if (p) { p.st = 'pending'; p.elapsed = 0; }
|
||||
const f = sf[idx];
|
||||
const sz = f ? fs(f.size) : '';
|
||||
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
|
||||
liveFileEl.textContent = currentLiveFile;
|
||||
});
|
||||
activeES.addEventListener('extract_done', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
procExtractDone = true;
|
||||
// per-file символы -> оценка времени для ожидающих файлов
|
||||
if (d.per_file) {
|
||||
for (const nm in d.per_file) {
|
||||
const idx = procNameIdx[nm];
|
||||
if (idx != null && procState[idx]) procState[idx].chars = d.per_file[nm];
|
||||
}
|
||||
}
|
||||
});
|
||||
activeES.addEventListener('file_start', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const idx = sendIdx[d.idx];
|
||||
const p = procState[idx];
|
||||
if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; }
|
||||
});
|
||||
activeES.addEventListener('file_chunk', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const idx = sendIdx[d.idx];
|
||||
const p = procState[idx];
|
||||
if (p && typeof d.eta_sec === 'number') p.eta = d.eta_sec;
|
||||
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name +
|
||||
(typeof d.eta_sec === 'number' ? ' — осталось ~' + fmtSec(d.eta_sec) : '');
|
||||
});
|
||||
activeES.addEventListener('file_done', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const idx = sendIdx[d.idx];
|
||||
const p = procState[idx];
|
||||
if (p) { p.st = 'analyzed'; }
|
||||
});
|
||||
activeES.addEventListener('llm', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.active) {
|
||||
liveLlmTime.textContent = d.elapsed + ' с';
|
||||
liveLlm.classList.add('show');
|
||||
// LLM анализирует ВСЕ файлы вместе — не показываем имя конкретного
|
||||
liveFileEl.textContent = '🤖 ИИ анализирует все файлы…';
|
||||
if (typeof d.eta_sec === 'number' && d.eta_sec >= 0) liveEta.textContent = '⏳ осталось ~' + fmtSec(d.eta_sec);
|
||||
else liveEta.textContent = '';
|
||||
} else {
|
||||
liveLlm.classList.remove('show');
|
||||
if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
|
||||
@@ -587,19 +740,41 @@ async function uploadFiles() {
|
||||
activeES.addEventListener('done', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const idx = sendIdx[d.idx];
|
||||
clearInterval(fileIntervals[idx]);
|
||||
if (!procExtractDone) {
|
||||
// done на этапе извлечения = битый/пропущенный файл
|
||||
const p0 = procState[idx];
|
||||
if (p0) p0.st = 'skipped';
|
||||
return;
|
||||
}
|
||||
// Время на КОНКРЕТНЫЙ файл приходит с бэка (extract_text + замена, без общего LLM)
|
||||
const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
|
||||
? d.elapsed.toFixed(1)
|
||||
: ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1);
|
||||
ss(idx, '<span style="color:#22c55e">✓ ' + sec + 'с</span>');
|
||||
const p = procState[idx];
|
||||
if (p) { p.st = 'done'; p.elapsed = d.elapsed > 0 ? d.elapsed : parseFloat(sec); }
|
||||
else procState[idx] = { st: 'done', elapsed: parseFloat(sec), eta: null, est: null, chars: 0, t0: 0 };
|
||||
});
|
||||
activeES.addEventListener('cancelled', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
finishProcUI();
|
||||
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
st.className = 'status done';
|
||||
st.textContent = '⏹ Остановлено. Сохранено ' + d.saved + ' из ' + d.total + ' файлов + таблица замен (общее ' + totalSec + 'с)';
|
||||
const sb = document.getElementById('statsBlock');
|
||||
document.getElementById('stTotalTime').textContent = totalSec + ' с';
|
||||
if (d.llm_sec > 0) {
|
||||
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
|
||||
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
|
||||
} else {
|
||||
document.getElementById('stLlmTime').textContent = '—';
|
||||
document.getElementById('stLlmTokens').textContent = '—';
|
||||
}
|
||||
sb.classList.add('show');
|
||||
db.classList.add('show');
|
||||
resolve();
|
||||
});
|
||||
activeES.addEventListener('complete', function(e) {
|
||||
activeES.close();
|
||||
activeES = null;
|
||||
clearInterval(ptimer);
|
||||
clearInterval(liveRefresh);
|
||||
liveBlock.classList.remove('show');
|
||||
finishProcUI();
|
||||
const d = JSON.parse(e.data);
|
||||
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
st.className = 'status done';
|
||||
@@ -619,18 +794,12 @@ async function uploadFiles() {
|
||||
resolve();
|
||||
});
|
||||
activeES.onerror = function() {
|
||||
activeES.close();
|
||||
activeES = null;
|
||||
clearInterval(ptimer);
|
||||
clearInterval(liveRefresh);
|
||||
liveBlock.classList.remove('show');
|
||||
finishProcUI();
|
||||
reject(new Error('SSE connection failed'));
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
clearInterval(ptimer);
|
||||
clearInterval(liveRefresh);
|
||||
liveBlock.classList.remove('show');
|
||||
finishProcUI();
|
||||
st.className = 'status error';
|
||||
st.textContent = 'Ошибка: ' + err.message;
|
||||
}
|
||||
@@ -686,5 +855,19 @@ function downloadCsv() {
|
||||
<button class="btn" style="margin-top:12px;width:100%" onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cancelModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center" onclick="this.style.display='none'">
|
||||
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:440px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
|
||||
<h3 style="margin-bottom:12px">⏹ Остановить обработку?</h3>
|
||||
<p style="font-size:13px;color:var(--muted);line-height:1.6" id="cancelModalText">
|
||||
Будут сохранены: полностью обработанные файлы и таблица замен.
|
||||
Необработанные файлы в результат не попадут.
|
||||
Текущий анализ будет доведён до конца.
|
||||
</p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn" onclick="document.getElementById('cancelModal').style.display='none'">Отмена</button>
|
||||
<button class="btn btn-primary" onclick="doCancel()">Остановить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user