feat: integrate upload-platform v0.2.2 into drhider (v0.0.78)
Deploy drhider / validate (push) Canceled after 0s

- Update upload/ module to v0.2.2 with modular Layer 1 (FilePicker) and Layer 2 (Streaming transit upload)
- Replace legacy manual file table in site/templates/index.html with FilePicker.initFilePicker
- Wire uploadViaVM with per-file status updates and abort signal support
- Add dist bundles to dist/ and site/static/dist/ with routes in site/routes/main_bp.py
- Add test_hardening.py and test_safe_name.py from upload-platform
- Bump version to 0.0.78 in site/app.py
- Document integration plan and report in History/upload-integration/
This commit is contained in:
Repinoid
2026-09-15 12:23:31 +03:00
parent 3fd6f1cffc
commit 49bb4d70d8
47 changed files with 6355 additions and 1561 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях)
VERSION = "0.0.77"
VERSION = "0.0.78"
def setup_logging():
+15 -3
View File
@@ -13,9 +13,9 @@ from flask import Blueprint, render_template, current_app, send_from_directory
# ═══════════════════════════════════════════════════════════════════════════
# Корень модуля upload/frontend — для раздачи ES-модулей браузеру
_UPLOAD_FRONTEND_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"upload", "frontend")
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_UPLOAD_FRONTEND_DIR = os.path.join(_ROOT_DIR, "upload", "frontend")
_DIST_DIR = os.path.join(_ROOT_DIR, "dist")
main_bp = Blueprint("main", __name__)
@@ -34,3 +34,15 @@ def index():
def upload_frontend(filename):
"""Раздаёт ES-модули переиспользуемого слоя загрузки (upload/frontend)."""
return send_from_directory(_UPLOAD_FRONTEND_DIR, filename)
@main_bp.route("/file-picker/<path:filename>")
def file_picker_dist(filename):
"""Отдаёт собранный бандл из каталога dist."""
return send_from_directory(_DIST_DIR, filename)
@main_bp.route("/dist/<path:filename>")
def dist_files(filename):
"""Отдаёт файлы из каталога dist."""
return send_from_directory(_DIST_DIR, filename)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+220 -252
View File
@@ -49,38 +49,47 @@
}
.card-body { padding: 12px; }
.sub { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.5; }
.file-input-wrap { margin-bottom: 10px; }
.file-input-wrap input[type="file"] { width: 100%; font-size: 13px; }
.table-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
/* FilePicker styling */
.file-picker { font-family: inherit; color: var(--text); }
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
.file-picker .fp-toolbar button {
height: 32px; border-radius: 6px; padding: 0 12px; font-size: 13px;
font-family: inherit; cursor: pointer; border: 1px solid var(--brand-gray);
background: var(--card); color: var(--text);
display: inline-flex; align-items: center; gap: 5px; font-weight: 500;
}
.file-picker .fp-toolbar button#fp-files-btn { background: #eff6ff; border-color: var(--brand-primary); color: var(--brand-primary); }
.file-picker .fp-toolbar button#fp-folder-btn { background: var(--card); }
.file-picker .fp-toolbar button#fp-clear-btn { color: var(--muted); border-color: transparent; }
.file-picker .fp-toolbar button#fp-clear-btn:hover { color: var(--red); }
.file-picker .fp-table-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow: hidden; margin-bottom: 6px; }
.file-picker .fp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.file-picker .fp-table th {
background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 8px;
border-right: 1px solid var(--brand-gray); text-align: left;
font-weight: 600; font-size: 10px; color: var(--muted);
border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray);
text-align: left; font-weight: 600; font-size: 10px; color: var(--muted);
}
td {
padding: 5px 8px; border-right: 1px solid var(--brand-gray);
border-bottom: 1px solid var(--brand-gray);
.file-picker .fp-table td {
padding: 5px 8px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray);
}
tr:hover td { background: rgba(37,99,235,.03); }
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.num-cell { text-align: right; white-space: nowrap; }
.empty-row td { color: var(--muted); text-align: center; padding: 20px; }
.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;
.file-picker .tree-row:hover td { background: rgba(37,99,235,.03); }
.file-picker .tree-row.row-current td { background: rgba(37,99,235,.12) !important; color: #1d4ed8; font-weight: 600; }
.file-picker .tree-name {
display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent;
color: inherit; font: inherit; text-align: left; cursor: pointer;
}
.remove-btn {
.file-picker .tree-chevron { display: inline-block; width: 12px; color: var(--muted); font-size: 9px; }
.file-picker .fp-status { font-size: 12px; color: var(--muted); margin: 4px 0; }
.file-picker .fp-count { display: none; }
.file-picker .remove-btn {
cursor: pointer; color: #f87171; background: none; border: none;
padding: 2px 4px; font-size: 14px; line-height: 1;
}
.remove-btn:hover { color: var(--red); }
body.busy .remove-btn { display: none; }
.file-picker .remove-btn:hover { color: var(--red); }
body.busy .file-picker button { pointer-events: none; opacity: 0.5; }
body.busy .file-picker .remove-btn { display: none; }
.footer-bar {
margin-top: 10px; display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: var(--muted);
@@ -146,29 +155,12 @@
<p class="sub">
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP с обезличенными копиями.<br>
CSV с таблицей замен скачивается отдельно.<br>
<span style="color:#c0392b;">Ограничения: один файл не более 50 МБ, суммарно не более 500 МБ. Файлы сверх лимитов помечаются красным (статус «пропущен») и не участвуют в обфускации. Пустые, повреждённые файлы и сканы без текстового слоя получают статус «не извлечён» — они тоже не попадают в результат.</span><br>
<span style="color:#c0392b;">Ограничения: один файл не более 50 МБ, суммарно не более 500 МБ. Файлы сверх лимитов помечаются статусом «пропущен» и не участвуют в обфускации. Пустые, повреждённые файлы и сканы без текстового слоя получают статус «не извлечён» — они тоже не попадают в результат.</span><br>
<span style="color:#7d3c98;">⏱ Время обработки каждого файла — ориентировочное (обновляется по факту).</span>
</p>
<div class="file-input-wrap">
<input type="file" id="fileInput" multiple accept=".doc,.docx,.pdf,.txt,.zip">
<button type="button" class="btn" id="folderBtn" onclick="document.getElementById('folderInput').click()">📁 Выбрать папку</button>
<input type="file" id="folderInput" webkitdirectory multiple style="display:none">
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th style="width:80px;">Размер</th>
<th style="width:80px;">Статус</th>
<th style="width:32px;"></th>
</tr>
</thead>
<tbody id="fileList">
<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>
</tbody>
</table>
</div>
<div id="filePicker"></div>
<div class="footer-bar">
<span id="fileCount">0 файлов</span>
<button class="btn" id="cancelBtn" style="display:none" onclick="confirmCancel()">⏹ Прервать</button>
@@ -207,93 +199,55 @@
</div>
</div>
</div>
<script type="module">
import { initUploadTable } from '/upload/table/init_upload_table.js';
import { uploadViaVM } from '/upload/upload/upload_via_vm.js';
import { fs } from '/upload/table/fs.js';
const fi = document.getElementById('fileInput');
const folderInput = document.getElementById('folderInput');
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md']; // документы (из папки/архивов)
const fl = document.getElementById('fileList');
const fc = document.getElementById('fileCount');
<script src="/file-picker/file-picker.iife.js?v={{ version }}"></script>
<script>
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md'];
const ub = document.getElementById('uploadBtn');
const cb = document.getElementById('cancelBtn');
const nb = document.getElementById('newSessionBtn');
const fc = document.getElementById('fileCount');
const st = document.getElementById('status');
const db = document.getElementById('dlBtns');
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 больших файлов (шлюз кластера их рвёт)
// Тест-режим: открыть страницу с ?upload-only=1 → только загрузка, без обработки
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер
const TEST_UPLOAD_ONLY = new URLSearchParams(location.search).get('upload-only') === '1';
let currentSid = '';
let activeES = null; // активный EventSource
let activeXHR = null; // активный XHR
let activeES = null;
let abortCtrl = null;
let busy = false;
let sessionDone = false;
let ptimer = null, liveRefresh = null;
// ── Состояние обработки (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 procExtractRate = null; // измеренная скорость извлечения (сек/МБ) для оценок ожидающих
let procRefresh = null; // setInterval перерисовки таблицы
let ptimer = null, liveRefresh = null; // таймеры статуса и live-блока
let sessionDone = false; // результат готов — сессия заморожена до «Новая сессия»
// Слой 1: выбор файлов/папки/архива (состояние внутри модуля)
const table = initUploadTable({
const picker = FilePicker.initFilePicker({
mount: '#filePicker',
allowedExt: DOC_EXTS,
maxFileBytes: MAX_FILE_BYTES,
maxSessionBytes: MAX_SESSION_BYTES,
estMbSec: 12,
}, {
fileInput: fi,
folderInput: folderInput,
tableBody: fl,
countEl: fc,
uploadBtnEl: ub,
onStatus(cls, text) {
st.className = cls ? 'status ' + cls : '';
st.textContent = text;
labels: {
pickFiles: '📄 Выбрать файлы',
pickFolder: '📁 Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: '✕',
columns: { path: 'Имя', size: 'Размер', status: 'Статус' },
},
limits: {
maxEntryBytes: MAX_FILE_BYTES,
maxTotalBytes: MAX_SESSION_BYTES,
maxEntries: 1000,
maxDepth: 20,
},
onChange: (files) => {
ub.disabled = files.length === 0 || busy;
fc.textContent = files.length ? `${files.length} файл(ов)` : '0 файлов';
}
});
// Связать состояние обработки с рендером модуля (renderProcTable читает state.proc)
table.state.proc = {};
function syncProcCtx() {
table.state.proc.phase = procPhase;
table.state.proc.procState = procState;
table.state.proc.procExtractRate = procExtractRate;
function setBusy(b) {
busy = b;
document.body.classList.toggle('busy', b);
}
syncProcCtx();
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 = '';
procPhase = 'idle';
procState = {};
procNameIdx = {};
procExtractDone = false;
procExtractRate = null;
sessionDone = false;
setBusy(false);
syncProcCtx();
table.clear();
document.getElementById('newSessionBtn').style.display = 'none';
document.getElementById('cancelBtn').style.display = 'none';
st.className = '';
st.textContent = '';
db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show');
document.getElementById('liveBlock').classList.remove('show');
// Кнопку «Обфусцировать» НЕ перекрываем: table.clear() уже поставил disabled при пустом списке
}
// При F5 / закрытии вкладки — обрубить всё
window.addEventListener('beforeunload', () => resetAll());
function fmtSec(s) {
s = Math.max(0, Math.round(s));
@@ -306,192 +260,212 @@ function confirmCancel() {
function doCancel() {
document.getElementById('cancelModal').style.display = 'none';
if (!currentSid) return;
fetch('/api/cancel/' + currentSid, { method: 'POST' }).catch(() => {});
st.textContent = '⏹ Остановка… завершаем текущий файл';
if (abortCtrl) {
abortCtrl.abort();
}
if (currentSid) {
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';
syncProcCtx();
table.render();
cb.style.display = 'none';
setBusy(false);
}
function setBusy(b) {
table.setBusy(b);
document.body.classList.toggle('busy', b); // скрывает кнопки «✕» в таблице
}
function ss(idx, h) { table.setStatus(idx, h); }
async function uploadFiles() {
if (table.state.busy) return; // уже идёт загрузка/обработка
if (table.state.files.length === 0) return;
setBusy(true);
sessionDone = false;
document.getElementById('newSessionBtn').style.display = 'none';
// Только учитываемые файлы идут на загрузку/обфускацию (сверхлимитные пропускаем)
const files = table.state.files.slice();
const toSend = [];
const sendIdx = []; // sendIdx[k] = индекс в files для отправленного файла
for (let i = 0; i < files.length; i++) {
if (!table.state.overNames.has(files[i].name)) { toSend.push(files[i]); sendIdx.push(i); }
else { ss(i, '<span style="color:#c0392b;">пропущен (лимит)</span>'); }
}
// Обрубить всё что могло остаться от предыдущего раза
function resetAll() {
if (activeES) { activeES.close(); activeES = null; }
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
if (ptimer) { clearInterval(ptimer); ptimer = null; }
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
currentSid = '';
sessionDone = false;
setBusy(false);
picker.clear();
nb.style.display = 'none';
cb.style.display = 'none';
st.className = '';
st.textContent = '';
db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show');
document.getElementById('liveBlock').classList.remove('show');
ub.disabled = true;
const total = toSend.length;
if (total === 0) { st.className = 'status error'; st.textContent = 'Нет файлов для обфускации (все превышают лимит).'; setBusy(false); ub.disabled = false; return; }
}
// Фаза 1+1b: загрузка через ВМ-буфер (слой 2 модуля: PUT + POST /api/upload_refs)
let uploadedCount = 0;
const res = await uploadViaVM(toSend, VM_UPLOAD_URL, {
session: currentSid,
onStatus(k, html) { ss(sendIdx[k], html); },
onUploadStatus(text) { st.className = 'status progress'; st.textContent = text; },
onXHR(xhr) { activeXHR = xhr; },
});
if (!res.ok) {
window.addEventListener('beforeunload', () => resetAll());
async function uploadFiles() {
if (busy) return;
const files = picker.getFiles();
if (!files || files.length === 0) return;
setBusy(true);
sessionDone = false;
nb.style.display = 'none';
cb.style.display = 'inline-block';
ub.disabled = true;
if (activeES) { activeES.close(); activeES = null; }
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
abortCtrl = new AbortController();
currentSid = '';
st.className = 'status progress';
st.textContent = 'Подготовка к загрузке через ВМ-буфер…';
db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show');
document.getElementById('liveBlock').classList.remove('show');
const total = files.length;
let res;
try {
res = await FilePicker.uploadViaVM(files, {
vmUploadUrl: VM_UPLOAD_URL,
backendUploadUrl: '/api/upload_refs',
signal: abortCtrl.signal,
onStatus: (msg) => {
st.className = 'status progress';
st.textContent = msg;
},
onFileStatus: (k, statusText) => {
const rows = document.querySelectorAll('.tree-row.tree-file');
if (rows[k]) {
const td = rows[k].querySelectorAll('td')[2];
if (td) td.innerHTML = statusText;
}
}
});
} catch (err) {
st.className = 'status error';
st.textContent = res.error;
st.textContent = 'Ошибка загрузки: ' + err.message;
setBusy(false);
cb.style.display = 'none';
ub.disabled = false;
return;
}
currentSid = res.session;
uploadedCount = res.count;
// Тест-режим (?upload-only=1): только загрузка, без обработки
if (!res.ok) {
if (res.aborted) {
st.className = 'status';
st.textContent = '⏹ Загрузка отменена пользователем.';
} else {
st.className = 'status error';
st.textContent = res.error || 'Ошибка загрузки';
}
setBusy(false);
cb.style.display = 'none';
ub.disabled = false;
return;
}
currentSid = res.session;
const uploadedCount = res.count || total;
if (TEST_UPLOAD_ONLY) {
st.className = 'status done';
st.textContent = '✅ Загружено ' + uploadedCount + ' файлов (тест: обработка пропущена). Сессия: ' + currentSid;
setBusy(false);
cb.style.display = 'none';
ub.disabled = false;
nb.style.display = 'inline-block';
return;
}
// Фаза 2: обработка (SSE — прогресс по каждому файлу)
// Сброс загрузочных статусов — теперь этап обработки (только отправленные)
for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>');
// Инициализация 3-секционной таблицы (готово / текущий / ожидают)
procPhase = 'processing';
procState = {};
procNameIdx = {};
procExtractDone = false;
procExtractRate = null;
for (let k = 0; k < total; k++) {
procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 };
}
syncProcCtx();
document.getElementById('cancelBtn').style.display = 'inline-block';
if (procRefresh) clearInterval(procRefresh);
procRefresh = setInterval(() => table.render(), 1000); // тикающий рендер (elapsed текущего файла)
table.render();
const fileTimers = {}; // idx -> performance.now()
const fileIntervals = {}; // idx -> setInterval id
await startProcessingPhase(total, files);
}
async function startProcessingPhase(total, files) {
st.className = 'status progress';
st.textContent = 'Обработка (этап 2/2)…';
const t0 = performance.now();
ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
}, 1000);
cb.style.display = 'inline-block';
const rows = document.querySelectorAll('.tree-row.tree-file');
rows.forEach(r => {
const td = r.querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:#2563eb">⏳</span>';
});
// Крупный live-блок «Идёт обработка» с тикающим таймером
const liveBlock = document.getElementById('liveBlock');
const liveTimerEl = document.getElementById('liveTimer');
const liveFileEl = document.getElementById('liveFile');
const liveLlm = document.getElementById('liveLlm');
const liveLlmTime = document.getElementById('liveLlmTime');
const liveEta = document.getElementById('liveEta');
let currentLiveFile = ''; // последнее имя файла из start
let currentLiveFile = '';
liveLlm.classList.remove('show');
liveEta.textContent = '';
liveTimerEl.textContent = '0.0 с';
liveFileEl.textContent = 'Подготовка…';
liveBlock.classList.add('show');
const t0 = performance.now();
if (ptimer) clearInterval(ptimer);
ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
}, 1000);
if (liveRefresh) clearInterval(liveRefresh);
liveRefresh = setInterval(() => {
const sec = ((performance.now() - t0) / 1000).toFixed(1);
liveTimerEl.textContent = sec + ' с';
}, 200);
let procExtractDone = false;
const fileTimers = {};
try {
await new Promise((resolve, reject) => {
activeES = new EventSource('/api/process_stream/' + currentSid);
activeES.addEventListener('start', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx]; // индекс в files для вывода прогресса
procNameIdx[d.name] = idx;
const p = procState[idx];
if (p) { p.st = 'pending'; p.elapsed = 0; }
// Этап извлечения: текущий файл = обрабатываемый сейчас (тикер в таблице).
// Предыдущий «текущий» демотируем и замеряем его скорость (сек/МБ) для оценок.
if (!procExtractDone) {
let prevIdx = null;
for (const i in procState) {
if (procState[i].st === 'current') { prevIdx = Number(i); procState[i].st = 'pending'; }
}
if (prevIdx != null && table.state.files[prevIdx] && procState[prevIdx].t0) {
const el = (performance.now() - procState[prevIdx].t0) / 1000;
const szMB = table.state.files[prevIdx].size / 1048576;
if (el > 0 && szMB > 0) procExtractRate = el / szMB;
}
if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; }
const idx = d.idx;
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (curRows[idx]) {
curRows.forEach(r => r.classList.remove('row-current'));
curRows[idx].classList.add('row-current');
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:#2563eb">⚙️</span>';
}
syncProcCtx();
const f = table.state.files[idx];
const sz = f ? fs(f.size) : '';
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name;
liveFileEl.textContent = currentLiveFile;
});
activeES.addEventListener('extract_done', function(e) {
const d = JSON.parse(e.data);
procExtractDone = true;
// Этап извлечения завершён — снимаем подсветку «текущего» извлечения
for (const i in procState) {
if (procState[i].st === 'current') procState[i].st = 'pending';
}
// 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];
}
}
const curRows = document.querySelectorAll('.tree-row.tree-file');
curRows.forEach(r => r.classList.remove('row-current'));
});
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; }
const idx = d.idx;
fileTimers[idx] = performance.now();
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (curRows[idx]) {
curRows.forEach(r => r.classList.remove('row-current'));
curRows[idx].classList.add('row-current');
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:#2563eb">⚙️ анализ…</span>';
}
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name;
});
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'; }
const idx = d.idx;
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (curRows[idx]) {
curRows[idx].classList.remove('row-current');
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '✅ готов';
}
});
activeES.addEventListener('llm', function(e) {
const d = JSON.parse(e.data);
@@ -508,20 +482,22 @@ async function uploadFiles() {
});
activeES.addEventListener('done', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
const idx = d.idx;
const curRows = document.querySelectorAll('.tree-row.tree-file');
if (!procExtractDone) {
// done на этапе извлечения = битый/пропущенный файл
const p0 = procState[idx];
if (p0) p0.st = 'skipped';
if (curRows[idx]) {
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '<span style="color:#c0392b;">не извлечён</span>';
}
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);
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 };
if (curRows[idx]) {
const td = curRows[idx].querySelectorAll('td')[2];
if (td) td.innerHTML = '✅ ' + sec + 'с';
}
});
activeES.addEventListener('cancelled', function(e) {
const d = JSON.parse(e.data);
@@ -540,11 +516,9 @@ async function uploadFiles() {
}
sb.classList.add('show');
db.classList.add('show');
// Заморозка сессии: результат готов (частичный), список файлов больше не меняем
sessionDone = true;
ub.disabled = true;
fi.disabled = true;
document.getElementById('newSessionBtn').style.display = 'inline-block';
nb.style.display = 'inline-block';
resolve();
});
activeES.addEventListener('complete', function(e) {
@@ -553,7 +527,6 @@ async function uploadFiles() {
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done';
st.textContent = '✅ Обработано ' + d.total + ' файлов: общее ' + totalSec + 'с, из них ИИ ' + (d.llm_sec > 0 ? d.llm_sec + 'с' : '0с');
// Крупный блок статистики
const sb = document.getElementById('statsBlock');
document.getElementById('stTotalTime').textContent = totalSec + ' с';
if (d.llm_sec > 0) {
@@ -565,11 +538,9 @@ async function uploadFiles() {
}
sb.classList.add('show');
db.classList.add('show');
// Заморозка сессии: результат готов, список файлов больше не меняем
sessionDone = true;
ub.disabled = true;
fi.disabled = true;
document.getElementById('newSessionBtn').style.display = 'inline-block';
nb.style.display = 'inline-block';
resolve();
});
activeES.addEventListener('proc_error', function(e) {
@@ -632,7 +603,6 @@ function downloadCsv() {
});
}
// Экспонировать в window для onclick-атрибутов в HTML
window.uploadFiles = uploadFiles;
window.resetAll = resetAll;
window.confirmCancel = confirmCancel;
@@ -640,8 +610,6 @@ window.doCancel = doCancel;
window.downloadZip = downloadZip;
window.downloadCsv = downloadCsv;
// Инициализация: пустой список + прогрев upstream-соединения
table.render();
fetch('/health').catch(() => {});
</script>
<div id="helpModal" 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'">