feat: интегрировать модуль upload в drhider (бэк blueprint + фронт ES-модули)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
from .main_bp import main_bp
|
from .main_bp import main_bp
|
||||||
from .health_bp import health_bp
|
from .health_bp import health_bp
|
||||||
from .api_bp import api_bp
|
from .api_bp import api_bp
|
||||||
|
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
@@ -12,3 +13,13 @@ def register_routes(app):
|
|||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(health_bp)
|
app.register_blueprint(health_bp)
|
||||||
app.register_blueprint(api_bp)
|
app.register_blueprint(api_bp)
|
||||||
|
# Слой 2 (закачка через ВМ) — переиспользуемый blueprint из модуля upload
|
||||||
|
app.register_blueprint(create_upload_refs_blueprint({
|
||||||
|
"apiPrefix": "/api",
|
||||||
|
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
|
||||||
|
"maxFileBytes": 50 * 1024 * 1024,
|
||||||
|
"maxSessionBytes": 500 * 1024 * 1024,
|
||||||
|
"ttlSeconds": 30 * 60,
|
||||||
|
"pullRetries": 3,
|
||||||
|
"pullRetryDelay": 2,
|
||||||
|
}))
|
||||||
|
|||||||
+6
-113
@@ -17,41 +17,19 @@ import time
|
|||||||
import zipfile
|
import zipfile
|
||||||
import traceback
|
import traceback
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
|
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
|
||||||
|
|
||||||
from drhider import obfuscate_files, LLMClient
|
from drhider import obfuscate_files, LLMClient
|
||||||
from session import (create_session, add_file, get_files, store_result,
|
from upload.backend.session import (create_session, add_file, get_files, store_result,
|
||||||
get_result, store_csv, get_csv, cleanup, file_count,
|
get_result, store_csv, get_csv, cleanup, file_count,
|
||||||
MAX_FILE_BYTES, pause_ttl, resume_ttl,
|
MAX_FILE_BYTES, pause_ttl, resume_ttl,
|
||||||
request_cancel, get_cancel_event)
|
request_cancel, get_cancel_event)
|
||||||
|
from upload.backend.upload_refs import safe_name
|
||||||
|
|
||||||
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
log = logging.getLogger("routes.api_bp")
|
log = logging.getLogger("routes.api_bp")
|
||||||
|
|
||||||
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
|
|
||||||
PULL_RETRIES = 3
|
|
||||||
PULL_RETRY_DELAY = 2 # секунды между попытками
|
|
||||||
|
|
||||||
# Доверенный префикс ВМ-буфера — валидация URL при pull (защита от SSRF)
|
|
||||||
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_name(name: str) -> str:
|
|
||||||
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
|
|
||||||
|
|
||||||
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
|
|
||||||
имя пустое или небезопасное.
|
|
||||||
"""
|
|
||||||
if not name:
|
|
||||||
return ""
|
|
||||||
name = name.replace("\\", "/")
|
|
||||||
parts = [p for p in name.split("/") if p and p != "."]
|
|
||||||
if not parts or any(p == ".." for p in parts):
|
|
||||||
return ""
|
|
||||||
return "/".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _disconnect_exceptions():
|
def _disconnect_exceptions():
|
||||||
"""Исключения, означающие отключение клиента SSE."""
|
"""Исключения, означающие отключение клиента SSE."""
|
||||||
@@ -72,7 +50,7 @@ def upload():
|
|||||||
added = 0
|
added = 0
|
||||||
had_unnamed = False
|
had_unnamed = False
|
||||||
for f in uploaded:
|
for f in uploaded:
|
||||||
name = _safe_name(f.filename)
|
name = safe_name(f.filename)
|
||||||
if not name:
|
if not name:
|
||||||
had_unnamed = True
|
had_unnamed = True
|
||||||
continue
|
continue
|
||||||
@@ -96,91 +74,6 @@ def upload():
|
|||||||
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
|
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/upload_refs", methods=["POST"])
|
|
||||||
def upload_refs():
|
|
||||||
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
|
|
||||||
|
|
||||||
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
|
|
||||||
Каждый файл тянется ИСХОДЯЩИМ GET'ом с ВМ (egress не ограничен шлюзом),
|
|
||||||
читается по частям (stream), кладётся в сессию. После успешного pull файл
|
|
||||||
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
|
|
||||||
"""
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
sid = data.get("session") or create_session()
|
|
||||||
refs = data.get("files") or []
|
|
||||||
if not refs:
|
|
||||||
log.warning("upload_refs: no files, sid=%s", sid)
|
|
||||||
return jsonify({"ok": False, "error": "No files"}), 400
|
|
||||||
added = 0
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=120, follow_redirects=True) as client:
|
|
||||||
for ref in refs:
|
|
||||||
name = _safe_name(ref.get("name") or "")
|
|
||||||
url = ref.get("url")
|
|
||||||
if not name or not url:
|
|
||||||
continue
|
|
||||||
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
|
|
||||||
if not url.startswith(VM_UPLOAD_PREFIX):
|
|
||||||
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
|
|
||||||
continue
|
|
||||||
# Лимит на один файл (50 МБ): сверх лимита — пропускаем (не участвует)
|
|
||||||
if (ref.get("size") or 0) > MAX_FILE_BYTES:
|
|
||||||
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
|
|
||||||
MAX_FILE_BYTES // (1024 * 1024), sid, name, ref.get("size"))
|
|
||||||
try:
|
|
||||||
client.delete(url)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
continue
|
|
||||||
# Pull с ретраями: разовые DNS/сетевые сбои не роняют всю загрузку
|
|
||||||
content = None
|
|
||||||
last_err = None
|
|
||||||
for attempt in range(PULL_RETRIES):
|
|
||||||
try:
|
|
||||||
with client.stream("GET", url) as resp:
|
|
||||||
resp.raise_for_status()
|
|
||||||
content = b"".join(resp.iter_bytes())
|
|
||||||
last_err = None
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
last_err = e
|
|
||||||
log.warning("upload_refs: pull attempt %d/%d failed sid=%s file=%r: %r",
|
|
||||||
attempt + 1, PULL_RETRIES, sid, name, e)
|
|
||||||
time.sleep(PULL_RETRY_DELAY)
|
|
||||||
if content is None:
|
|
||||||
raise last_err if last_err else RuntimeError("pull failed")
|
|
||||||
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
|
|
||||||
if len(content) > MAX_FILE_BYTES:
|
|
||||||
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
|
|
||||||
MAX_FILE_BYTES // (1024 * 1024), sid, name, len(content))
|
|
||||||
try:
|
|
||||||
client.delete(url)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
continue
|
|
||||||
if not add_file(sid, name, content):
|
|
||||||
# Различить: сессия исчезла vs превышен суммарный лимит сессии
|
|
||||||
if get_files(sid) is None:
|
|
||||||
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
|
|
||||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
|
||||||
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
|
|
||||||
try:
|
|
||||||
client.delete(url)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
client.delete(url) # убрать файл с ВМ после загрузки
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
added += 1
|
|
||||||
except Exception as e:
|
|
||||||
log.error("upload_refs: pull error sid=%s: %r", sid, e)
|
|
||||||
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
|
|
||||||
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
|
|
||||||
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/session_files/<sid>", methods=["GET"])
|
@api_bp.route("/session_files/<sid>", methods=["GET"])
|
||||||
def session_files(sid):
|
def session_files(sid):
|
||||||
"""Отладка: список файлов сессии с размерами (для теста загрузки)."""
|
"""Отладка: список файлов сессии с размерами (для теста загрузки)."""
|
||||||
|
|||||||
+14
-1
@@ -4,12 +4,19 @@ Blueprint: главная страница (GET /).
|
|||||||
Отдаёт HTML-интерфейс DrHider.
|
Отдаёт HTML-интерфейс DrHider.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, render_template, current_app
|
import os
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, current_app, send_from_directory
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Blueprint: главная страница
|
# Blueprint: главная страница
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Корень модуля upload/frontend — для раздачи ES-модулей браузеру
|
||||||
|
_UPLOAD_FRONTEND_DIR = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"upload", "frontend")
|
||||||
|
|
||||||
main_bp = Blueprint("main", __name__)
|
main_bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -21,3 +28,9 @@ def index():
|
|||||||
"""
|
"""
|
||||||
version = current_app.config.get("VERSION", "0.0.0")
|
version = current_app.config.get("VERSION", "0.0.0")
|
||||||
return render_template("index.html", version=version)
|
return render_template("index.html", version=version)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/upload/<path:filename>")
|
||||||
|
def upload_frontend(filename):
|
||||||
|
"""Раздаёт ES-модули переиспользуемого слоя загрузки (upload/frontend)."""
|
||||||
|
return send_from_directory(_UPLOAD_FRONTEND_DIR, filename)
|
||||||
|
|||||||
+86
-440
@@ -207,7 +207,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<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 fi = document.getElementById('fileInput');
|
||||||
const folderInput = document.getElementById('folderInput');
|
const folderInput = document.getElementById('folderInput');
|
||||||
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md']; // документы (из папки/архивов)
|
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md']; // документы (из папки/архивов)
|
||||||
@@ -216,19 +220,6 @@ const fc = document.getElementById('fileCount');
|
|||||||
const ub = document.getElementById('uploadBtn');
|
const ub = document.getElementById('uploadBtn');
|
||||||
const st = document.getElementById('status');
|
const st = document.getElementById('status');
|
||||||
const db = document.getElementById('dlBtns');
|
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 procExtractRate = null; // измеренная скорость извлечения (сек/МБ) для оценок ожидающих
|
|
||||||
let procRefresh = null; // setInterval перерисовки таблицы
|
|
||||||
let ptimer = null, liveRefresh = null; // таймеры статуса и live-блока
|
|
||||||
let busy = false; // идёт загрузка/обработка — список файлов заблокирован
|
|
||||||
let sessionDone = false; // результат готов — сессия заморожена до «Новая сессия»
|
|
||||||
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл
|
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл
|
||||||
const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
|
const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
|
||||||
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
|
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
|
||||||
@@ -238,6 +229,43 @@ let currentSid = '';
|
|||||||
let activeES = null; // активный EventSource
|
let activeES = null; // активный EventSource
|
||||||
let activeXHR = null; // активный XHR
|
let activeXHR = null; // активный XHR
|
||||||
|
|
||||||
|
// ── Состояние обработки (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({
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Связать состояние обработки с рендером модуля (renderProcTable читает state.proc)
|
||||||
|
table.state.proc = {};
|
||||||
|
function syncProcCtx() {
|
||||||
|
table.state.proc.phase = procPhase;
|
||||||
|
table.state.proc.procState = procState;
|
||||||
|
table.state.proc.procExtractRate = procExtractRate;
|
||||||
|
}
|
||||||
|
syncProcCtx();
|
||||||
|
|
||||||
function resetAll() {
|
function resetAll() {
|
||||||
if (activeES) { activeES.close(); activeES = null; }
|
if (activeES) { activeES.close(); activeES = null; }
|
||||||
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
|
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
|
||||||
@@ -245,9 +273,6 @@ function resetAll() {
|
|||||||
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
|
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
|
||||||
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
|
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
|
||||||
currentSid = '';
|
currentSid = '';
|
||||||
sf = [];
|
|
||||||
fileMeta = new Map();
|
|
||||||
overNames = new Set();
|
|
||||||
procPhase = 'idle';
|
procPhase = 'idle';
|
||||||
procState = {};
|
procState = {};
|
||||||
procNameIdx = {};
|
procNameIdx = {};
|
||||||
@@ -255,129 +280,26 @@ function resetAll() {
|
|||||||
procExtractRate = null;
|
procExtractRate = null;
|
||||||
sessionDone = false;
|
sessionDone = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
table.clear();
|
||||||
|
syncProcCtx();
|
||||||
document.getElementById('newSessionBtn').style.display = 'none';
|
document.getElementById('newSessionBtn').style.display = 'none';
|
||||||
document.getElementById('cancelBtn').style.display = 'none';
|
document.getElementById('cancelBtn').style.display = 'none';
|
||||||
fi.value = '';
|
|
||||||
rr();
|
|
||||||
st.className = '';
|
st.className = '';
|
||||||
st.textContent = '';
|
st.textContent = '';
|
||||||
db.classList.remove('show');
|
db.classList.remove('show');
|
||||||
document.getElementById('statsBlock').classList.remove('show');
|
document.getElementById('statsBlock').classList.remove('show');
|
||||||
document.getElementById('liveBlock').classList.remove('show');
|
document.getElementById('liveBlock').classList.remove('show');
|
||||||
// Кнопку «Обфусцировать» НЕ перекрываем: rr() уже поставил disabled при пустом списке
|
// Кнопку «Обфусцировать» НЕ перекрываем: table.clear() уже поставил disabled при пустом списке
|
||||||
}
|
}
|
||||||
|
|
||||||
// При F5 / закрытии вкладки — обрубить всё
|
// При F5 / закрытии вкладки — обрубить всё
|
||||||
window.addEventListener('beforeunload', () => resetAll());
|
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) {
|
function fmtSec(s) {
|
||||||
s = Math.max(0, Math.round(s));
|
s = Math.max(0, Math.round(s));
|
||||||
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
|
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Эмпирическая оценка времени обработки файла: сек/МБ (ориентировочно, до старта)
|
|
||||||
const EST_MB_SEC = 12;
|
|
||||||
function estForFile(f) { return f ? Math.max(1, Math.round(f.size / 1048576 * EST_MB_SEC)) : 0; }
|
|
||||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
const over = overNames.has(f.name);
|
|
||||||
const rowCls = over ? ' class="row-over"' : '';
|
|
||||||
const stTxt = over ? '<span style="color:#c0392b;">🔥 не учитывается</span>'
|
|
||||||
: '<span style="color:#7d3c98;">~' + fmtSec(estForFile(f)) + '</span>';
|
|
||||||
return '<tr id="row-' + i + '"' + rowCls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">' + stTxt + '</td><td><button class="remove-btn" onclick="rm(' + i + ')">✕</button></td></tr>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
const overCount = sf.filter(f => overNames.has(f.name)).length;
|
|
||||||
const totalSize = sf.reduce((s, f) => s + (overNames.has(f.name) ? 0 : f.size), 0);
|
|
||||||
const cntMain = sf.length - overCount;
|
|
||||||
const totEst = sf.reduce((s, f) => s + (overNames.has(f.name) ? 0 : estForFile(f)), 0);
|
|
||||||
fc.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : sf.length) + ' файлов · ' + fs(totalSize) + ' · ~' + fmtSec(totEst);
|
|
||||||
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">' + esc(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: [], over: [] };
|
|
||||||
for (let i = 0; i < sf.length; i++) {
|
|
||||||
if (overNames.has(sf[i].name)) { groups.over.push(i); continue; }
|
|
||||||
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;" title="Не удалось прочитать файл: пустой, повреждённый или скан без текста">не извлечён</span>'
|
|
||||||
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
|
|
||||||
rows.push(procRow(i, txt));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (groups.over.length) {
|
|
||||||
rows.push('<tr class="grp-row"><td colspan="4">⛔ Пропущены (сверх лимита) (' + groups.over.length + ')</td></tr>');
|
|
||||||
for (const i of groups.over) {
|
|
||||||
rows.push(procRow(i, '<span style="color:#c0392b;">пропущен (лимит)</span>'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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] || {};
|
|
||||||
// Оценка: LLM (chars x rate) если известна; иначе грубая по размеру/скорости извлечения
|
|
||||||
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
|
|
||||||
if (!p.est) {
|
|
||||||
const szMB = (sf[i] ? sf[i].size : 0) / 1048576;
|
|
||||||
const k = procExtractRate || EST_MB_SEC; // сек/МБ: замеренная или эмпирическая
|
|
||||||
p.est = Math.max(1, Math.round(szMB * k));
|
|
||||||
}
|
|
||||||
let txt;
|
|
||||||
if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
|
|
||||||
else if (p.st === 'current') txt = '<span style="color:#2563eb;">⏳</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() {
|
function confirmCancel() {
|
||||||
document.getElementById('cancelModal').style.display = 'flex';
|
document.getElementById('cancelModal').style.display = 'flex';
|
||||||
}
|
}
|
||||||
@@ -397,266 +319,29 @@ function finishProcUI() {
|
|||||||
document.getElementById('liveBlock').classList.remove('show');
|
document.getElementById('liveBlock').classList.remove('show');
|
||||||
document.getElementById('cancelBtn').style.display = 'none';
|
document.getElementById('cancelBtn').style.display = 'none';
|
||||||
procPhase = 'idle';
|
procPhase = 'idle';
|
||||||
rr();
|
syncProcCtx();
|
||||||
|
table.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBusy(b) {
|
function setBusy(b) {
|
||||||
busy = b;
|
table.setBusy(b);
|
||||||
fi.disabled = b; // «Выбрать файлы» — блокируется на время загрузки/обработки и в замороженной сессии
|
|
||||||
document.body.classList.toggle('busy', b); // скрывает кнопки «✕» в таблице
|
document.body.classList.toggle('busy', b); // скрывает кнопки «✕» в таблице
|
||||||
}
|
}
|
||||||
|
|
||||||
function rm(i) { if (busy) return; 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(); }
|
function ss(idx, h) { table.setStatus(idx, h); }
|
||||||
|
|
||||||
window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
|
|
||||||
// ═══════════ Нативная распаковка ZIP (без внешних библиотек) ═══════════
|
|
||||||
|
|
||||||
function dosToMs(date, time) {
|
|
||||||
const year = 1980 + ((date >> 9) & 0x7f);
|
|
||||||
const month = (date >> 5) & 0x0f;
|
|
||||||
const day = date & 0x1f;
|
|
||||||
const hour = (time >> 11) & 0x1f;
|
|
||||||
const min = (time >> 5) & 0x3f;
|
|
||||||
const sec = (time & 0x1f) * 2;
|
|
||||||
return new Date(year, month - 1, day, hour, min, sec).getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeZipName(bytes, isUtf8) {
|
|
||||||
if (isUtf8) return new TextDecoder('utf-8').decode(bytes);
|
|
||||||
// Многие архиваторы пишут имя в UTF-8, но НЕ выставляют UTF-8-флаг (bit 11).
|
|
||||||
// Сначала строго пробуем UTF-8: если байты — валидный UTF-8 с кириллицей/текстом,
|
|
||||||
// берём их как есть (иначе декодирование CP437→CP866 превратит их в «╨╣…»-мусор).
|
|
||||||
try {
|
|
||||||
const s = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
||||||
// Кириллица — точно UTF-8; либо чистый печатаемый текст без управляющих символов.
|
|
||||||
if (/[\u0400-\u04FF]/.test(s) || !/[^\u0020-\u007e]/.test(s)) return s;
|
|
||||||
} catch (e) { /* не UTF-8 — legacy (CP437/CP866) */ }
|
|
||||||
let name;
|
|
||||||
try { name = new TextDecoder('ibm437').decode(bytes); }
|
|
||||||
catch (e) { name = new TextDecoder('utf-8').decode(bytes); }
|
|
||||||
// Кириллица из 1С (CP866) — перекодировать, если имя пришло как CP437-мусор
|
|
||||||
if (/[^\x00-\x7f]/.test(name)) {
|
|
||||||
try { name = new TextDecoder('ibm866').decode(bytes); }
|
|
||||||
catch (e) { /* оставить как есть */ }
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inflateRaw(bytes) {
|
|
||||||
const ds = new DecompressionStream('deflate-raw');
|
|
||||||
const stream = new Blob([bytes]).stream().pipeThrough(ds);
|
|
||||||
const ab = await new Response(stream).arrayBuffer();
|
|
||||||
return new Uint8Array(ab);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseZip(buf) {
|
|
||||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
||||||
let eocd = -1;
|
|
||||||
for (let i = buf.length - 22; i >= 0; i--) {
|
|
||||||
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
|
|
||||||
}
|
|
||||||
if (eocd < 0) throw new Error('Не ZIP');
|
|
||||||
const cdSize = dv.getUint32(eocd + 12, true);
|
|
||||||
const cdOffset = dv.getUint32(eocd + 16, true);
|
|
||||||
|
|
||||||
const entries = [];
|
|
||||||
let pos = cdOffset;
|
|
||||||
const cdEnd = cdOffset + cdSize;
|
|
||||||
while (pos < cdEnd) {
|
|
||||||
if (dv.getUint32(pos, true) !== 0x02014b50) break;
|
|
||||||
const flags = dv.getUint16(pos + 8, true);
|
|
||||||
const method = dv.getUint16(pos + 10, true);
|
|
||||||
const modTime = dv.getUint16(pos + 12, true);
|
|
||||||
const modDate = dv.getUint16(pos + 14, true);
|
|
||||||
const compSize = dv.getUint32(pos + 20, true);
|
|
||||||
const nameLen = dv.getUint16(pos + 28, true);
|
|
||||||
const extraLen = dv.getUint16(pos + 30, true);
|
|
||||||
const commentLen = dv.getUint16(pos + 32, true);
|
|
||||||
const localOffset = dv.getUint32(pos + 42, true);
|
|
||||||
|
|
||||||
const nameBytes = buf.slice(pos + 46, pos + 46 + nameLen);
|
|
||||||
const name = decodeZipName(nameBytes, (flags & 0x800) !== 0);
|
|
||||||
|
|
||||||
const lhNameLen = dv.getUint16(localOffset + 26, true);
|
|
||||||
const lhExtraLen = dv.getUint16(localOffset + 28, true);
|
|
||||||
const dataStart = localOffset + 30 + lhNameLen + lhExtraLen;
|
|
||||||
const comp = buf.slice(dataStart, dataStart + compSize);
|
|
||||||
|
|
||||||
let data;
|
|
||||||
if (method === 0) data = comp;
|
|
||||||
else if (method === 8) data = await inflateRaw(comp);
|
|
||||||
else throw new Error('Метод сжатия ' + method + ' не поддерживается');
|
|
||||||
|
|
||||||
entries.push({ name, data, dosMs: dosToMs(modDate, modTime), isDir: name.endsWith('/') });
|
|
||||||
pos += 46 + nameLen + extraLen + commentLen;
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listZipFiles(file) {
|
|
||||||
const buf = new Uint8Array(await file.arrayBuffer());
|
|
||||||
const entries = await parseZip(buf);
|
|
||||||
const out = [];
|
|
||||||
// Из ZIP вытаскиваем только документы. Всё прочее (изображения и т.п.) пропускаем.
|
|
||||||
const allowedExt = ['.pdf', '.doc', '.docx', '.txt', '.md'];
|
|
||||||
for (const e of entries) {
|
|
||||||
if (e.isDir) continue;
|
|
||||||
const low = e.name.toLowerCase();
|
|
||||||
if (low.endsWith('.zip')) {
|
|
||||||
const sub = new File([e.data], e.name, { lastModified: e.dosMs });
|
|
||||||
out.push(...(await listZipFiles(sub)));
|
|
||||||
} else if (allowedExt.some(ext => low.endsWith(ext))) {
|
|
||||||
out.push(new File([e.data], e.name, { lastModified: e.dosMs }));
|
|
||||||
}
|
|
||||||
// иначе — не документ, пропускаем
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFileWithDedup(file) {
|
|
||||||
const size = file.size;
|
|
||||||
const mtime = file.lastModified;
|
|
||||||
let name = file.name;
|
|
||||||
|
|
||||||
if (fileMeta.has(name)) {
|
|
||||||
const e = fileMeta.get(name);
|
|
||||||
if (e.size === size) {
|
|
||||||
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить
|
|
||||||
// с тем же содержимым. Оставляем более свежий по дате.
|
|
||||||
if (mtime > e.mtime) {
|
|
||||||
e.mtime = mtime;
|
|
||||||
const idx = sf.findIndex(f => f.name === name);
|
|
||||||
if (idx >= 0) sf[idx] = new File([file], name, { lastModified: mtime });
|
|
||||||
}
|
|
||||||
return false; // дедуп: файл не добавлен (обновлена только дата)
|
|
||||||
}
|
|
||||||
// Имя то же, размер другой — добавить с суффиксом _2, _3...
|
|
||||||
const dot = name.lastIndexOf('.');
|
|
||||||
const base = dot > 0 ? name.slice(0, dot) : name;
|
|
||||||
const ext = dot > 0 ? name.slice(dot) : '';
|
|
||||||
let n = 2;
|
|
||||||
while (fileMeta.has(base + '_' + n + ext)) n++;
|
|
||||||
name = base + '_' + n + ext;
|
|
||||||
}
|
|
||||||
fileMeta.set(name, { size, mtime });
|
|
||||||
|
|
||||||
// Определяем, превышает ли файл лимит (по размеру файла или суммарный) — не участвует в обфускации
|
|
||||||
let over = false;
|
|
||||||
if (size > MAX_FILE_BYTES) over = true; // лимит на один файл — 50 МБ
|
|
||||||
const sum = sf.reduce((s, f) => s + (overNames.has(f.name) ? 0 : f.size), 0);
|
|
||||||
if (sum + size > MAX_SESSION_BYTES) over = true; // суммарный лимит сессии — 500 МБ
|
|
||||||
|
|
||||||
if (over) {
|
|
||||||
overNames.add(name);
|
|
||||||
sf.push(new File([file], name, { lastModified: mtime }));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
sf.push(new File([file], name, { lastModified: mtime }));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fi.addEventListener('change', async () => {
|
|
||||||
if (busy) return; // во время загрузки/обработки менять список нельзя
|
|
||||||
const incoming = Array.from(fi.files);
|
|
||||||
const hasZip = incoming.some(f => f.name.toLowerCase().endsWith('.zip'));
|
|
||||||
if (hasZip) {
|
|
||||||
// Распаковка архивов может занять время — показать индикатор
|
|
||||||
document.body.style.cursor = 'wait';
|
|
||||||
st.className = 'status progress';
|
|
||||||
st.textContent = 'Разбираю архивы…';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
for (const f of incoming) {
|
|
||||||
if (f.name.toLowerCase().endsWith('.zip')) {
|
|
||||||
try {
|
|
||||||
const nested = await listZipFiles(f);
|
|
||||||
if (nested.length) nested.forEach(x => addFileWithDedup(x));
|
|
||||||
else addFileWithDedup(f); // в архиве нет документов — добавить архив как есть
|
|
||||||
} catch (err) {
|
|
||||||
addFileWithDedup(f); // не удалось распаковать — добавить zip как есть
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addFileWithDedup(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (hasZip) {
|
|
||||||
document.body.style.cursor = '';
|
|
||||||
st.className = '';
|
|
||||||
st.textContent = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const d = new DataTransfer();
|
|
||||||
sf.forEach(f => d.items.add(f));
|
|
||||||
fi.files = d.files;
|
|
||||||
rr();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══ Выбор целой папки (webkitdirectory): рекурсивно, относительный путь сохраняется ═══
|
|
||||||
folderInput.addEventListener('change', async () => {
|
|
||||||
if (busy) return; // во время загрузки/обработки менять список нельзя
|
|
||||||
const incoming = Array.from(folderInput.files);
|
|
||||||
if (!incoming.length) return;
|
|
||||||
document.body.style.cursor = 'wait';
|
|
||||||
st.className = 'status progress';
|
|
||||||
st.textContent = 'Разбираю папку…';
|
|
||||||
let added = 0;
|
|
||||||
try {
|
|
||||||
for (const f of incoming) {
|
|
||||||
// webkitRelativePath: "TopFolder/Подпапка/file.pdf" — отбрасываем верхнюю папку
|
|
||||||
const parts = (f.webkitRelativePath || f.name).split('/');
|
|
||||||
const rel = parts.slice(1).join('/') || f.name;
|
|
||||||
const low = rel.toLowerCase();
|
|
||||||
const slashIdx = rel.lastIndexOf('/');
|
|
||||||
const relDir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '';
|
|
||||||
if (low.endsWith('.zip')) {
|
|
||||||
try {
|
|
||||||
const nested = await listZipFiles(f);
|
|
||||||
if (nested.length) {
|
|
||||||
for (const nf of nested) {
|
|
||||||
const nm = relDir ? relDir + '/' + nf.name : nf.name;
|
|
||||||
if (addFileWithDedup(new File([nf], nm, { lastModified: nf.lastModified }))) added++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// в архиве нет документов — добавить архив как есть, чтобы не терялся
|
|
||||||
if (addFileWithDedup(new File([f], rel, { lastModified: f.lastModified }))) added++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (addFileWithDedup(new File([f], rel, { lastModified: f.lastModified }))) added++; // zip как есть
|
|
||||||
}
|
|
||||||
} else if (DOC_EXTS.some(e => low.endsWith(e))) {
|
|
||||||
if (addFileWithDedup(new File([f], rel, { lastModified: f.lastModified }))) added++;
|
|
||||||
}
|
|
||||||
// иначе — не документ, пропускаем
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
document.body.style.cursor = '';
|
|
||||||
}
|
|
||||||
folderInput.value = ''; // чтобы повторный выбор той же папки сработал
|
|
||||||
rr();
|
|
||||||
if (added) {
|
|
||||||
st.className = 'status done';
|
|
||||||
const n = added;
|
|
||||||
const w = (n % 10 === 1 && n % 100 !== 11) ? 'файл' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 'файла' : 'файлов';
|
|
||||||
st.textContent = '✅ Добавлено из папки: ' + n + ' ' + w;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
|
|
||||||
|
|
||||||
async function uploadFiles() {
|
async function uploadFiles() {
|
||||||
if (busy) return; // уже идёт загрузка/обработка
|
if (table.state.busy) return; // уже идёт загрузка/обработка
|
||||||
if (sf.length === 0) return;
|
if (table.state.files.length === 0) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
sessionDone = false;
|
sessionDone = false;
|
||||||
document.getElementById('newSessionBtn').style.display = 'none';
|
document.getElementById('newSessionBtn').style.display = 'none';
|
||||||
// Сохранить список до очистки
|
// Только учитываемые файлы идут на загрузку/обфускацию (сверхлимитные пропускаем)
|
||||||
const files = sf.slice();
|
const files = table.state.files.slice();
|
||||||
// Только учитываемые файлы идут на загрузку/обфускацию (переборные пропускаем)
|
|
||||||
const toSend = [];
|
const toSend = [];
|
||||||
const sendIdx = []; // sendIdx[k] = индекс в sf для отправленного файла
|
const sendIdx = []; // sendIdx[k] = индекс в files для отправленного файла
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
if (!overNames.has(files[i].name)) { toSend.push(files[i]); sendIdx.push(i); }
|
if (!table.state.overNames.has(files[i].name)) { toSend.push(files[i]); sendIdx.push(i); }
|
||||||
else { ss(i, '<span style="color:#c0392b;">пропущен (лимит)</span>'); }
|
else { ss(i, '<span style="color:#c0392b;">пропущен (лимит)</span>'); }
|
||||||
}
|
}
|
||||||
// Обрубить всё что могло остаться от предыдущего раза
|
// Обрубить всё что могло остаться от предыдущего раза
|
||||||
@@ -672,75 +357,22 @@ async function uploadFiles() {
|
|||||||
const total = toSend.length;
|
const total = toSend.length;
|
||||||
if (total === 0) { st.className = 'status error'; st.textContent = 'Нет файлов для обфускации (все превышают лимит).'; setBusy(false); ub.disabled = false; return; }
|
if (total === 0) { st.className = 'status error'; st.textContent = 'Нет файлов для обфускации (все превышают лимит).'; setBusy(false); ub.disabled = false; return; }
|
||||||
|
|
||||||
// Фаза 1: загрузка на ВМ-буфер (PUT напрямую на nginx ВМ, минуя шлюз кластера)
|
// Фаза 1+1b: загрузка через ВМ-буфер (слой 2 модуля: PUT + POST /api/upload_refs)
|
||||||
const token = crypto.randomUUID();
|
let uploadedCount = 0;
|
||||||
const refs = []; // {name, size, url} — для POST /api/upload_refs
|
const res = await uploadViaVM(toSend, VM_UPLOAD_URL, {
|
||||||
let uploadedCount = 0; // сколько файлов реально легло в сессию (для тест-режима)
|
session: currentSid,
|
||||||
for (let k = 0; k < total; k++) {
|
onStatus(k, html) { ss(sendIdx[k], html); },
|
||||||
const f = toSend[k];
|
onUploadStatus(text) { st.className = 'status progress'; st.textContent = text; },
|
||||||
const i = sendIdx[k]; // индекс в sf для вывода прогресса
|
});
|
||||||
const n = k + 1;
|
if (!res.ok) {
|
||||||
st.className = 'status progress';
|
|
||||||
st.textContent = 'Загрузка на ВМ (этап 1/2) ' + n + '/' + total + ': ' + f.name;
|
|
||||||
try {
|
|
||||||
const t0 = performance.now();
|
|
||||||
const vmUrl = VM_UPLOAD_URL + token + '_' + k; // имя файла в URL не несём (токен-ключ)
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('PUT', vmUrl);
|
|
||||||
xhr.timeout = 300000; // 300с: большие файлы
|
|
||||||
xhr.upload.onprogress = function(e) {
|
|
||||||
if (e.lengthComputable) {
|
|
||||||
const pct = Math.round(e.loaded / e.total * 100);
|
|
||||||
ss(i, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.onload = function() {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
const elapsed = (performance.now() - t0) / 1000;
|
|
||||||
const speed = f.size / elapsed;
|
|
||||||
ss(i, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
|
|
||||||
refs.push({ name: f.name, size: f.size, url: vmUrl });
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error('ВМ: HTTP ' + xhr.status));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
|
|
||||||
xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); };
|
|
||||||
activeXHR = xhr;
|
|
||||||
xhr.send(f); // сырое тело файла
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
ss(i, '<span style="color:#ef4444">✗</span>');
|
|
||||||
st.className = 'status error';
|
|
||||||
st.textContent = 'Ошибка загрузки на ВМ: ' + err.message;
|
|
||||||
setBusy(false);
|
|
||||||
ub.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг 1b: один маленький POST во Flask со ссылками на файлы ВМ (<64КБ)
|
|
||||||
st.className = 'status progress';
|
|
||||||
st.textContent = 'Передача ссылок в сервис…';
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/upload_refs', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ session: currentSid, files: refs })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.ok) throw new Error(data.error || 'HTTP ' + resp.status);
|
|
||||||
currentSid = data.session;
|
|
||||||
uploadedCount = data.count || refs.length;
|
|
||||||
} catch (err) {
|
|
||||||
st.className = 'status error';
|
st.className = 'status error';
|
||||||
st.textContent = 'Ошибка передачи ссылок: ' + err.message;
|
st.textContent = res.error;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
ub.disabled = false;
|
ub.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
currentSid = res.session;
|
||||||
|
uploadedCount = res.count;
|
||||||
|
|
||||||
// Тест-режим (?upload-only=1): только загрузка, без обработки
|
// Тест-режим (?upload-only=1): только загрузка, без обработки
|
||||||
if (TEST_UPLOAD_ONLY) {
|
if (TEST_UPLOAD_ONLY) {
|
||||||
@@ -763,10 +395,11 @@ async function uploadFiles() {
|
|||||||
for (let k = 0; k < total; k++) {
|
for (let k = 0; k < total; k++) {
|
||||||
procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 };
|
procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 };
|
||||||
}
|
}
|
||||||
|
syncProcCtx();
|
||||||
document.getElementById('cancelBtn').style.display = 'inline-block';
|
document.getElementById('cancelBtn').style.display = 'inline-block';
|
||||||
if (procRefresh) clearInterval(procRefresh);
|
if (procRefresh) clearInterval(procRefresh);
|
||||||
procRefresh = setInterval(rr, 1000); // тикающий рендер (elapsed текущего файла)
|
procRefresh = setInterval(() => table.render(), 1000); // тикающий рендер (elapsed текущего файла)
|
||||||
rr();
|
table.render();
|
||||||
const fileTimers = {}; // idx -> performance.now()
|
const fileTimers = {}; // idx -> performance.now()
|
||||||
const fileIntervals = {}; // idx -> setInterval id
|
const fileIntervals = {}; // idx -> setInterval id
|
||||||
st.className = 'status progress';
|
st.className = 'status progress';
|
||||||
@@ -800,7 +433,7 @@ async function uploadFiles() {
|
|||||||
activeES = new EventSource('/api/process_stream/' + currentSid);
|
activeES = new EventSource('/api/process_stream/' + currentSid);
|
||||||
activeES.addEventListener('start', function(e) {
|
activeES.addEventListener('start', function(e) {
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса
|
const idx = sendIdx[d.idx]; // индекс в files для вывода прогресса
|
||||||
procNameIdx[d.name] = idx;
|
procNameIdx[d.name] = idx;
|
||||||
const p = procState[idx];
|
const p = procState[idx];
|
||||||
if (p) { p.st = 'pending'; p.elapsed = 0; }
|
if (p) { p.st = 'pending'; p.elapsed = 0; }
|
||||||
@@ -811,14 +444,15 @@ async function uploadFiles() {
|
|||||||
for (const i in procState) {
|
for (const i in procState) {
|
||||||
if (procState[i].st === 'current') { prevIdx = Number(i); procState[i].st = 'pending'; }
|
if (procState[i].st === 'current') { prevIdx = Number(i); procState[i].st = 'pending'; }
|
||||||
}
|
}
|
||||||
if (prevIdx != null && sf[prevIdx] && procState[prevIdx].t0) {
|
if (prevIdx != null && table.state.files[prevIdx] && procState[prevIdx].t0) {
|
||||||
const el = (performance.now() - procState[prevIdx].t0) / 1000;
|
const el = (performance.now() - procState[prevIdx].t0) / 1000;
|
||||||
const szMB = sf[prevIdx].size / 1048576;
|
const szMB = table.state.files[prevIdx].size / 1048576;
|
||||||
if (el > 0 && szMB > 0) procExtractRate = el / szMB;
|
if (el > 0 && szMB > 0) procExtractRate = el / szMB;
|
||||||
}
|
}
|
||||||
if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; }
|
if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; }
|
||||||
}
|
}
|
||||||
const f = sf[idx];
|
syncProcCtx();
|
||||||
|
const f = table.state.files[idx];
|
||||||
const sz = f ? fs(f.size) : '';
|
const sz = f ? fs(f.size) : '';
|
||||||
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
|
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
|
||||||
liveFileEl.textContent = currentLiveFile;
|
liveFileEl.textContent = currentLiveFile;
|
||||||
@@ -996,6 +630,18 @@ function downloadCsv() {
|
|||||||
URL.revokeObjectURL(a.href);
|
URL.revokeObjectURL(a.href);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Экспонировать в window для onclick-атрибутов в HTML
|
||||||
|
window.uploadFiles = uploadFiles;
|
||||||
|
window.resetAll = resetAll;
|
||||||
|
window.confirmCancel = confirmCancel;
|
||||||
|
window.doCancel = doCancel;
|
||||||
|
window.downloadZip = downloadZip;
|
||||||
|
window.downloadCsv = downloadCsv;
|
||||||
|
|
||||||
|
// Инициализация: пустой список + прогрев upstream-соединения
|
||||||
|
table.render();
|
||||||
|
fetch('/health').catch(() => {});
|
||||||
</script>
|
</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'">
|
<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'">
|
||||||
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
|
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
|
||||||
|
|||||||
Reference in New Issue
Block a user