Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1750a8497f | ||
|
|
09016b22e8 | ||
|
|
523b52a26c | ||
|
|
67a4caa43c | ||
|
|
05a035471d | ||
|
|
bfe487164f | ||
|
|
df0576aebd | ||
|
|
f7687d3f12 | ||
|
|
2b202795b8 | ||
|
|
5e2c90f8db | ||
|
|
8316e19725 | ||
|
|
860cdd4214 | ||
|
|
fca6f6aafb | ||
|
|
87de22306f | ||
|
|
8768fd96ec |
@@ -0,0 +1,17 @@
|
|||||||
|
# 2026-09-06: правило документирования и частых коммитов
|
||||||
|
|
||||||
|
## Подтверждённое правило работы
|
||||||
|
|
||||||
|
Пользователь явно потребовал:
|
||||||
|
|
||||||
|
- документировать все обнаруженные факты, решения и результаты сразу;
|
||||||
|
- делать отдельный коммит после каждой правки или логического изменения;
|
||||||
|
- не накапливать несколько независимых изменений до одного общего коммита.
|
||||||
|
|
||||||
|
## Результат текущего шага
|
||||||
|
|
||||||
|
- UX-правка таймера и пояснения загрузки зафиксирована коммитом `87de223`.
|
||||||
|
- Коммит `87de223` успешно отправлен в `origin/master`.
|
||||||
|
- На момент документирования локальная ветка `master` синхронизирована с `origin/master`.
|
||||||
|
|
||||||
|
Это правило применяется к дальнейшей работе в проекте upload-platform.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 2026-09-06: план дополнительных hardening-тестов
|
||||||
|
|
||||||
|
После полного прогона `0.2.2` выявлены тестовые пробелы, которые не покрывались штатными 10 Python и 10 Node тестами:
|
||||||
|
|
||||||
|
- границы `MAX_FILE_BYTES` (ровно лимит и лимит + 1 байт);
|
||||||
|
- превышение фактического размера файла после pull при заниженном заявленном размере;
|
||||||
|
- граница `MAX_SESSION_BYTES`;
|
||||||
|
- retry pull после временного HTTP-сбоя;
|
||||||
|
- удаление сессии по TTL;
|
||||||
|
- удаление файла из буфера при пропуске по лимиту;
|
||||||
|
- отмена frontend после частично завершённого пофайлового транзита;
|
||||||
|
- повторный выбор/двойной запуск picker без параллельной обработки.
|
||||||
|
|
||||||
|
Первым шагом добавляются backend hardening-тесты для лимитов, retry, TTL и очистки буфера. После каждого логического изменения выполняется отдельный коммит и проверка.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 2026-09-06: результат дополнительных hardening-тестов
|
||||||
|
|
||||||
|
## Добавленные тесты
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
- точное достижение `MAX_SESSION_BYTES` и отказ при переполнении;
|
||||||
|
- отказ при фактическом размере файла больше `MAX_FILE_BYTES`, даже если заявленный размер допустим;
|
||||||
|
- обязательное удаление oversize-файла из буфера;
|
||||||
|
- retry pull после временного HTTP `503`;
|
||||||
|
- удаление файла после успешного pull;
|
||||||
|
- автоматическое удаление RAM-сессии по короткому TTL.
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- отмена после первого успешно доставленного файла;
|
||||||
|
- подтверждение, что второй `PUT` не начинается;
|
||||||
|
- подтверждение частичного результата и `aborted: true`;
|
||||||
|
- проверка callback статуса доставленного первого файла.
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
- hardening backend: `4/4` PASS;
|
||||||
|
- полный Node suite: `11/11` PASS;
|
||||||
|
- полный Python suite: `14/14` PASS;
|
||||||
|
- syntax sweep: `22` Python-файла и `13` JavaScript-файлов PASS;
|
||||||
|
- frontend build: PASS;
|
||||||
|
- runtime `/health`: `ok`;
|
||||||
|
- runtime `/`: HTTP 200, `10035` байт;
|
||||||
|
- `git diff --check`: PASS.
|
||||||
|
|
||||||
|
## Исправленные ошибки тестового harness
|
||||||
|
|
||||||
|
- Для backend pull использован корректный `httpx.WSGITransport`.
|
||||||
|
- Конфигурационный ключ лимита приведён к API-формату `maxFileBytes`.
|
||||||
|
- Timing partial-cancel теста синхронизирован с первым `PUT`.
|
||||||
|
- Assertion статуса приведён к фактическому callback-тексту `✓ Доставлен`.
|
||||||
|
|
||||||
|
Production-код не изменялся, поэтому версия остаётся `0.2.2`.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 2026-09-06: группы слоя 1 по умолчанию свёрнуты
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
|
||||||
|
При большом количестве файлов в папках и ZIP раскрытая таблица становится неудобной: все файлы сразу занимают много строк, и пользователь вынужден вручную сворачивать группы.
|
||||||
|
|
||||||
|
Требование: папки и ZIP должны быть свёрнуты по умолчанию, с возможностью раскрывать каждую группу отдельно.
|
||||||
|
|
||||||
|
## Найденная причина
|
||||||
|
|
||||||
|
Рендер слоя 1 уже поддерживал `expanded === false`: дочерние строки не выводятся, а кнопка группы показывает стрелку раскрытия. Проблема находилась в создании узлов:
|
||||||
|
|
||||||
|
- корень ZIP и вложенные папки создавались с `expanded: true`;
|
||||||
|
- корни выбранных папок и промежуточные папки также создавались с `expanded: true`.
|
||||||
|
|
||||||
|
## План исправления
|
||||||
|
|
||||||
|
- сделать `expanded: false` только для групп `folder` и `zip`;
|
||||||
|
- оставить leaf-файлы обычными строками;
|
||||||
|
- сохранить независимое ручное раскрытие групп;
|
||||||
|
- добавить регрессионный тест рендера свернутой группы.
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
|
||||||
|
- узлы `folder` и `zip` теперь создаются с `expanded: false`;
|
||||||
|
- leaf-файлы доступны после раскрытия родительской группы;
|
||||||
|
- ручное раскрытие каждой группы сохранено;
|
||||||
|
- добавлен регрессионный тест рендера свернутой папки;
|
||||||
|
- пересобраны ESM и IIFE-бандлы в `dist/`.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- `npm run build` выполнен успешно;
|
||||||
|
- `npm test`: `10/10` тестов пройдены;
|
||||||
|
- `python3 -m pytest tests/ -q`: `10/10` тестов пройдены;
|
||||||
|
- `python3 -m py_compile site/app.py` выполнен успешно;
|
||||||
|
- `git diff --check` без замечаний.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 2026-09-06: Финальная фиксация состояния Слоя 2
|
||||||
|
|
||||||
|
## Репозиторий
|
||||||
|
|
||||||
|
- Проект: `upload-platform`.
|
||||||
|
- Основная ветка: `master`.
|
||||||
|
- Рабочее дерево на момент фиксации чистое.
|
||||||
|
- Основной коммит реализации: `112c84f`.
|
||||||
|
- Изменения отправлены в `origin/master`.
|
||||||
|
- Контрольное состояние сохранено в ветке `checkpoint-layer2-complete`, опубликованной в origin.
|
||||||
|
|
||||||
|
## Реализованное состояние
|
||||||
|
|
||||||
|
В `upload-platform` реализованы и изолированы два API-слоя:
|
||||||
|
|
||||||
|
1. **Слой 1: File Picker**
|
||||||
|
- Выбор файлов и папок.
|
||||||
|
- Клиентская распаковка ZIP и вложенных архивов.
|
||||||
|
- Дедупликация, фильтрация и лимиты.
|
||||||
|
- Контракт: `picker.getFiles()` возвращает плоский список объектов `{ path, name, size, file }`.
|
||||||
|
|
||||||
|
2. **Слой 2: Per-file Transit**
|
||||||
|
- Для каждого файла выполняется последовательность `PUT` в буфер ВМ -> `POST /api/upload_refs` -> исходящий потоковый `GET` в RAM сессии -> `DELETE` из буфера ВМ.
|
||||||
|
- Следующий файл начинает передаваться только после завершения текущего.
|
||||||
|
- В буфере ВМ одновременно находится не более одного файла.
|
||||||
|
- Файлы сессии хранятся только в оперативной памяти.
|
||||||
|
- URL буфера задаются конфигурацией через `vmUploadUrl` и `vmUploadPrefix`; боевые домены не зашиваются в интеграционный код.
|
||||||
|
- Входящие URL проходят SSRF-проверку, имена файлов санитизируются.
|
||||||
|
- Для будущего Слоя 3 предусмотрен callback `onFileReceived(sid, name, content)`.
|
||||||
|
|
||||||
|
## Автономный тестовый стенд
|
||||||
|
|
||||||
|
`site/app.py` содержит RAM-only mock WebDAV с операциями `PUT`, `GET`, `DELETE` и маршрутом статуса. Это позволяет тестировать полный транзит без внешней ВМ:
|
||||||
|
|
||||||
|
`browser -> mock buffer -> upload_refs -> RAM session -> Layer 3 callback`.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- `npm run build`: успешно.
|
||||||
|
- `npm test`: 9 тестов пройдены.
|
||||||
|
- `pytest tests/ -v`: 10 тестов пройдены.
|
||||||
|
- Итого: 19 тестов, все пройдены.
|
||||||
|
- Приложение `site/app.py` импортируется и регистрирует 11 маршрутов.
|
||||||
|
|
||||||
|
## Версия и документация
|
||||||
|
|
||||||
|
- Версия платформы: `0.2.0`.
|
||||||
|
- Обновлены `README.md` и `upload/README.md`.
|
||||||
|
- Техническое задание: `LAYER2-RESUME.md`.
|
||||||
|
- Подробный отчёт реализации: `HISTORY/2026-09-06-layer2-implementation-and-tests.md`.
|
||||||
|
|
||||||
|
Слой 3 в текущую реализацию не входит; он подключается потребляющим сервисом через API сессии и callback-контракт.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 2026-09-06: UX-индикация ожидания первого файла
|
||||||
|
|
||||||
|
## Наблюдение
|
||||||
|
|
||||||
|
При начале загрузки пользователь мог увидеть длительное отсутствие визуального прогресса на первом файле. Причина: первый PUT ещё не успел вызвать событие `XHR upload.onprogress`, а цепочка дополнительно выполняет первичное установление соединений и передачу файла в буфер ВМ.
|
||||||
|
|
||||||
|
Это не является зависанием, но без пояснения пользователь может нажать «Отмена».
|
||||||
|
|
||||||
|
## Изменение
|
||||||
|
|
||||||
|
В demo `site/templates/index.html` добавлены:
|
||||||
|
|
||||||
|
- отдельный блок текущей фазы загрузки;
|
||||||
|
- пояснение, что выполняются соединение с буфером ВМ, PUT, перенос в RAM и очистка буфера;
|
||||||
|
- таймер `Прошло: MM:SS`, запускающийся до первого сетевого ответа;
|
||||||
|
- остановка таймера при завершении, ошибке или отмене;
|
||||||
|
- сообщение о том, что уже переданные до отмены файлы остаются в RAM сессии.
|
||||||
|
|
||||||
|
В `PLAN.md` добавлен TODO следующей UX-правки: отдельные этапы `PUT`, `GET`, `DELETE`, прогресс пачки, обратная связь при ретраях и проверка мобильного сценария.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- `node build.mjs` выполнен успешно.
|
||||||
|
- `node --test tests/test_upload_layer2.test.mjs`: 9 тестов пройдены.
|
||||||
|
- `python3 -m pytest tests/ -v`: 10 тестов пройдены.
|
||||||
|
- Версия приложения повышена до `0.2.1` в `package.json` и `site/app.py`.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 2026-09-06: полный тест релиза 0.2.2
|
||||||
|
|
||||||
|
## Релиз
|
||||||
|
|
||||||
|
Проверен коммит `f7687d3` с версией `0.2.2`. Локальная ветка на момент проверки совпадала с `origin/master`.
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
- `npm run build` — успешно;
|
||||||
|
- `npm test` — `10/10` Node-тестов пройдены;
|
||||||
|
- `python3 -m pytest tests/ -v` — `10/10` Python-тестов пройдены;
|
||||||
|
- `python3 -m py_compile site/app.py` — успешно;
|
||||||
|
- `node --version` — `v18.19.1`;
|
||||||
|
- `python` — `3.12.3`;
|
||||||
|
- `git diff --check` — без замечаний;
|
||||||
|
- запуск `python3 site/app.py` — успешный, Flask слушает `0.0.0.0:5000`;
|
||||||
|
- живой `GET /health` — HTTP 200, тело `ok`;
|
||||||
|
- живой `GET /` — HTTP 200, получено `10035` байт HTML.
|
||||||
|
|
||||||
|
## Состав Node-тестов
|
||||||
|
|
||||||
|
Проверены:
|
||||||
|
|
||||||
|
- рендер папок по умолчанию в свёрнутом состоянии;
|
||||||
|
- успешный PUT в буфер ВМ;
|
||||||
|
- HTTP-ошибка PUT;
|
||||||
|
- сетевая ошибка;
|
||||||
|
- таймаут;
|
||||||
|
- отмена через `AbortSignal`;
|
||||||
|
- пофайловый транзит Layer 2;
|
||||||
|
- формат файлов `FilePicker.getFiles()`;
|
||||||
|
- ошибка PUT в `uploadViaVM`;
|
||||||
|
- отмена загрузки через signal.
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
Релиз `0.2.2` проходит полный автоматический и локальный runtime-прогон. После документирования запись должна быть отдельным коммитом и отправлена в `origin/master`.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 2026-09-06: жёсткий полный тест релиза 0.2.2
|
||||||
|
|
||||||
|
## Объём проверки
|
||||||
|
|
||||||
|
Проверен опубликованный релиз `0.2.2`, commit `df0576a` на `origin/master`.
|
||||||
|
|
||||||
|
- frontend build;
|
||||||
|
- весь Node test suite;
|
||||||
|
- весь Python test suite с `-vv --tb=long`;
|
||||||
|
- синтаксис всех исходных Python и JavaScript файлов;
|
||||||
|
- runtime запуск Flask;
|
||||||
|
- HTTP endpoints и раздача собранных бандлов;
|
||||||
|
- отрицательные API-сценарии безопасности и целостности транзита.
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
- `npm run build` — PASS;
|
||||||
|
- `npm test` — `10/10` PASS;
|
||||||
|
- `python3 -m pytest tests/ -vv --tb=long` — `10/10` PASS;
|
||||||
|
- `py_compile` — `22` Python-файла PASS;
|
||||||
|
- `node --check` — `13` JavaScript-файлов PASS;
|
||||||
|
- `git diff --check` — PASS;
|
||||||
|
- `/health` — HTTP 200, тело `ok`;
|
||||||
|
- `/` — HTTP 200;
|
||||||
|
- `/file-picker/file-picker.esm.js` — HTTP 200;
|
||||||
|
- `/file-picker/file-picker.iife.js` — HTTP 200;
|
||||||
|
- `/upload-frontend/index.js` — HTTP 200;
|
||||||
|
- Flask слушает `0.0.0.0:5000`.
|
||||||
|
|
||||||
|
## Отрицательные сценарии
|
||||||
|
|
||||||
|
Отдельный smoke-набор подтвердил:
|
||||||
|
|
||||||
|
- пустой `upload_refs` возвращает HTTP 400;
|
||||||
|
- malformed JSON возвращает HTTP 400;
|
||||||
|
- traversal-имя не попадает в сессию и не удаляет файл из буфера;
|
||||||
|
- SSRF URL вне разрешённого prefix пропускается без pull;
|
||||||
|
- валидный файл создаёт новую RAM-сессию;
|
||||||
|
- файл переносится в RAM;
|
||||||
|
- callback Layer 3 вызывается;
|
||||||
|
- файл удаляется из mock buffer после успешного pull;
|
||||||
|
- повторное чтение удалённого файла возвращает 404.
|
||||||
|
|
||||||
|
## Исправления harness во время проверки
|
||||||
|
|
||||||
|
Два первых запуска расширенного smoke-набора остановились в тестовом harness, не в приложении:
|
||||||
|
|
||||||
|
1. Для in-process Flask pull не был задан `httpx.WSGITransport`, поэтому backend получил `Connection refused`.
|
||||||
|
2. Следующий запуск передал несуществующий `session`; API корректно вернул сценарий `Session not found` вместо создания сессии. Новая сессия создаётся при отсутствии поля `session`.
|
||||||
|
|
||||||
|
После исправления harness smoke-набор завершился `strict_negative_smoke=PASS`.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Релиз `0.2.2` прошёл полный автоматический, синтаксический, runtime и отрицательный security-тест. Код не изменялся, поэтому версию не повышали.
|
||||||
@@ -9,6 +9,15 @@
|
|||||||
> Актуальное описание: [README.md](README.md) и
|
> Актуальное описание: [README.md](README.md) и
|
||||||
> [upload/README.md](upload/README.md). История перехода на picker-only и
|
> [upload/README.md](upload/README.md). История перехода на picker-only и
|
||||||
> исправлений находится в [HISTORY/2026-09-05-file-picker.md](HISTORY/2026-09-05-file-picker.md).
|
> исправлений находится в [HISTORY/2026-09-05-file-picker.md](HISTORY/2026-09-05-file-picker.md).
|
||||||
|
|
||||||
|
## TODO следующей правки: UX загрузки
|
||||||
|
|
||||||
|
- Показывать таймер и понятное пояснение уже до первого сетевого ответа.
|
||||||
|
- Отдельно отображать этапы `PUT в буфер ВМ`, `GET в RAM`, `DELETE из буфера`.
|
||||||
|
- Показывать прогресс пачки: текущий файл, общее количество, уже доставленные файлы.
|
||||||
|
- Не оставлять пользователя без обратной связи при DNS/TLS/CORS-задержке и повторных попытках.
|
||||||
|
- Перед отменой явно сообщать, сколько файлов уже доставлено в RAM и что будет сохранено.
|
||||||
|
- Проверить UX на мобильном экране и при медленном соединении.
|
||||||
>
|
>
|
||||||
> Весь текст ниже сохранён только как исторический план и не должен использоваться
|
> Весь текст ниже сохранён только как исторический план и не должен использоваться
|
||||||
> для новых изменений без отдельного пересмотра требований.
|
> для новых изменений без отдельного пересмотра требований.
|
||||||
|
|||||||
Vendored
-1194
File diff suppressed because it is too large
Load Diff
Vendored
-1218
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "upload-platform-file-picker",
|
"name": "upload-platform-file-picker",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.2.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.mjs",
|
"build": "node build.mjs",
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ try:
|
|||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
CONFIG = DEFAULT_CONFIG
|
CONFIG = DEFAULT_CONFIG
|
||||||
|
|
||||||
VERSION = "0.2.0"
|
VERSION = "0.2.2"
|
||||||
|
|
||||||
app = Flask(
|
app = Flask(
|
||||||
__name__,
|
__name__,
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ button.quiet { color: #5d6a61; background: transparent; }
|
|||||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
/* Резервная область сообщения интегратора сохраняет высоту и не двигает таблицу. */
|
/* Резервная область сообщения интегратора сохраняет высоту и не двигает таблицу. */
|
||||||
.status { min-height: 22px; color: #56745f; font: 13px sans-serif; }
|
.status { min-height: 22px; color: #56745f; font: 13px sans-serif; }
|
||||||
|
.upload-progress-explanation { display: grid; gap: 4px; margin-top: 14px; color: #46604e; font: 13px/1.45 sans-serif; }
|
||||||
|
.upload-progress-explanation strong { color: #1b4d3e; }
|
||||||
|
.upload-progress-explanation span:last-child { color: #6b796f; font-variant-numeric: tabular-nums; }
|
||||||
/* Горизонтальный scroll на узких экранах не ломает таблицу дерева. */
|
/* Горизонтальный scroll на узких экранах не ломает таблицу дерева. */
|
||||||
.table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
.table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
||||||
table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
|
table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
|
||||||
|
|||||||
+79
-15
@@ -30,6 +30,11 @@
|
|||||||
</button>
|
</button>
|
||||||
<span id="upload-status" style="font: 14px sans-serif; color: #2d4a36;"></span>
|
<span id="upload-status" style="font: 14px sans-serif; color: #2d4a36;"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="upload-progress-explanation" class="upload-progress-explanation" hidden>
|
||||||
|
<strong id="upload-phase">Подготовка загрузки</strong>
|
||||||
|
<span id="upload-phase-help">Первое соединение может занять время: устанавливаются соединения с буфером ВМ и сервисом.</span>
|
||||||
|
<span id="upload-elapsed">Прошло: 00:00</span>
|
||||||
|
</div>
|
||||||
<div id="session-info" style="margin-top: 14px; font: 13px sans-serif; display: none;">
|
<div id="session-info" style="margin-top: 14px; font: 13px sans-serif; display: none;">
|
||||||
<strong>Сессия в RAM:</strong> <code id="session-id"></code>
|
<strong>Сессия в RAM:</strong> <code id="session-id"></code>
|
||||||
<div id="session-files" style="margin-top: 6px; color: #46604e;"></div>
|
<div id="session-files" style="margin-top: 6px; color: #46604e;"></div>
|
||||||
@@ -45,9 +50,41 @@
|
|||||||
const sessionInfo = document.getElementById('session-info');
|
const sessionInfo = document.getElementById('session-info');
|
||||||
const sessionIdEl = document.getElementById('session-id');
|
const sessionIdEl = document.getElementById('session-id');
|
||||||
const sessionFilesEl = document.getElementById('session-files');
|
const sessionFilesEl = document.getElementById('session-files');
|
||||||
|
const progressExplanation = document.getElementById('upload-progress-explanation');
|
||||||
|
const uploadPhase = document.getElementById('upload-phase');
|
||||||
|
const uploadPhaseHelp = document.getElementById('upload-phase-help');
|
||||||
|
const uploadElapsed = document.getElementById('upload-elapsed');
|
||||||
|
|
||||||
let abortCtrl = null;
|
let abortCtrl = null;
|
||||||
let selectedFiles = [];
|
let selectedFiles = [];
|
||||||
|
let uploadTimer = null;
|
||||||
|
let uploadStartedAt = 0;
|
||||||
|
|
||||||
|
const formatElapsed = (seconds) => {
|
||||||
|
const minutes = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||||
|
const remainder = (seconds % 60).toString().padStart(2, '0');
|
||||||
|
return `${minutes}:${remainder}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopUploadTimer = () => {
|
||||||
|
if (uploadTimer) window.clearInterval(uploadTimer);
|
||||||
|
uploadTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startUploadTimer = () => {
|
||||||
|
stopUploadTimer();
|
||||||
|
uploadStartedAt = Date.now();
|
||||||
|
progressExplanation.hidden = false;
|
||||||
|
uploadTimer = window.setInterval(() => {
|
||||||
|
uploadElapsed.textContent = `Прошло: ${formatElapsed(Math.floor((Date.now() - uploadStartedAt) / 1000))}`;
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showUploadPhase = (phase, explanation) => {
|
||||||
|
progressExplanation.hidden = false;
|
||||||
|
uploadPhase.textContent = phase;
|
||||||
|
uploadPhaseHelp.textContent = explanation;
|
||||||
|
};
|
||||||
|
|
||||||
const picker = FilePicker.initFilePicker({
|
const picker = FilePicker.initFilePicker({
|
||||||
mount: '#file-picker',
|
mount: '#file-picker',
|
||||||
@@ -67,30 +104,55 @@
|
|||||||
cancelBtn.style.display = 'inline-block';
|
cancelBtn.style.display = 'inline-block';
|
||||||
sessionInfo.style.display = 'none';
|
sessionInfo.style.display = 'none';
|
||||||
abortCtrl = new AbortController();
|
abortCtrl = new AbortController();
|
||||||
|
startUploadTimer();
|
||||||
|
showUploadPhase(
|
||||||
|
'Первый файл: устанавливаем соединение',
|
||||||
|
'Это не зависание. Сначала файл отправляется в буфер ВМ, затем сервис забирает его в RAM и очищает буфер.'
|
||||||
|
);
|
||||||
|
|
||||||
const vmUploadUrl = new URL({{ config.vmUploadUrl|tojson }}, window.location.origin).href;
|
const vmUploadUrl = new URL({{ config.vmUploadUrl|tojson }}, window.location.origin).href;
|
||||||
|
|
||||||
const res = await FilePicker.uploadViaVM(files, {
|
let res;
|
||||||
vmUploadUrl: vmUploadUrl,
|
try {
|
||||||
backendUploadUrl: '/api/upload_refs',
|
res = await FilePicker.uploadViaVM(files, {
|
||||||
signal: abortCtrl.signal,
|
vmUploadUrl: vmUploadUrl,
|
||||||
onStatus: (msg) => { uploadStatus.textContent = msg; },
|
backendUploadUrl: '/api/upload_refs',
|
||||||
onFileStatus: (k, statusText) => {
|
signal: abortCtrl.signal,
|
||||||
const row = document.querySelectorAll('.tree-row.tree-file')[k];
|
onStatus: (msg) => {
|
||||||
if (row) {
|
uploadStatus.textContent = msg;
|
||||||
const statusCell = row.querySelectorAll('td')[2];
|
if (msg.startsWith('Загрузка в буфер')) {
|
||||||
if (statusCell) statusCell.innerHTML = statusText;
|
showUploadPhase('Отправка файла в буфер ВМ', 'Идёт PUT текущего файла. После этого сервис сразу перенесёт его в RAM.');
|
||||||
|
} else if (msg.startsWith('Приём сервисом')) {
|
||||||
|
showUploadPhase('Перенос файла в RAM', 'Сервис забирает текущий файл из буфера ВМ и удаляет его после успешного приёма.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFileStatus: (k, statusText) => {
|
||||||
|
const row = document.querySelectorAll('.tree-row.tree-file')[k];
|
||||||
|
if (row) {
|
||||||
|
const statusCell = row.querySelectorAll('td')[2];
|
||||||
|
if (statusCell) statusCell.innerHTML = statusText;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFileComplete: (info) => {
|
||||||
|
console.log('File delivered to RAM:', info);
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
onFileComplete: (info) => {
|
} catch (error) {
|
||||||
console.log('File delivered to RAM:', info);
|
stopUploadTimer();
|
||||||
}
|
cancelBtn.style.display = 'none';
|
||||||
});
|
uploadBtn.disabled = selectedFiles.length === 0;
|
||||||
|
showUploadPhase('Загрузка остановлена', 'Произошла непредвиденная ошибка. Повторите загрузку.');
|
||||||
|
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка загрузки</span>';
|
||||||
|
console.error('Upload failed unexpectedly', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopUploadTimer();
|
||||||
cancelBtn.style.display = 'none';
|
cancelBtn.style.display = 'none';
|
||||||
uploadBtn.disabled = selectedFiles.length === 0;
|
uploadBtn.disabled = selectedFiles.length === 0;
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
showUploadPhase('Загрузка завершена', 'Все файлы доставлены в RAM сессии.');
|
||||||
uploadStatus.innerHTML = '<span style="color:#1f5a3b; font-weight: bold;">✓ Все ' + res.count + ' файл(ов) успешно доставлены в RAM бэкенда!</span>';
|
uploadStatus.innerHTML = '<span style="color:#1f5a3b; font-weight: bold;">✓ Все ' + res.count + ' файл(ов) успешно доставлены в RAM бэкенда!</span>';
|
||||||
sessionIdEl.textContent = res.session;
|
sessionIdEl.textContent = res.session;
|
||||||
sessionInfo.style.display = 'block';
|
sessionInfo.style.display = 'block';
|
||||||
@@ -106,8 +168,10 @@
|
|||||||
console.error('Failed to load session files', e);
|
console.error('Failed to load session files', e);
|
||||||
}
|
}
|
||||||
} else if (res.aborted) {
|
} else if (res.aborted) {
|
||||||
|
showUploadPhase('Загрузка отменена', 'Переданные до отмены файлы уже находятся в RAM сессии.');
|
||||||
uploadStatus.innerHTML = '<span style="color:#8c5148;">Загрузка отменена пользователем</span>';
|
uploadStatus.innerHTML = '<span style="color:#8c5148;">Загрузка отменена пользователем</span>';
|
||||||
} else {
|
} else {
|
||||||
|
showUploadPhase('Загрузка остановлена', 'Проверьте сообщение об ошибке. Уже доставленные файлы остаются в RAM сессии.');
|
||||||
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка: ' + res.error + '</span>';
|
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка: ' + res.error + '</span>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import threading
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from upload.backend.session import add_file, cleanup, configure, create_session, get_files
|
||||||
|
from upload.backend.session import state as session_state
|
||||||
|
from upload.backend.upload_refs.blueprint import create_upload_refs_blueprint
|
||||||
|
|
||||||
|
|
||||||
|
def build_app(transport, **config):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['UPLOAD_HTTPX_TRANSPORT'] = transport
|
||||||
|
app.register_blueprint(create_upload_refs_blueprint({
|
||||||
|
'vmUploadPrefix': 'https://vm.test/buffer/',
|
||||||
|
'httpxTransport': transport,
|
||||||
|
'pullRetries': 3,
|
||||||
|
'pullRetryDelay': 0,
|
||||||
|
**config,
|
||||||
|
}))
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_limit_accepts_exact_boundary_and_rejects_overflow():
|
||||||
|
sid = create_session()
|
||||||
|
previous = session_state.MAX_SESSION_BYTES
|
||||||
|
configure(max_session_bytes=10)
|
||||||
|
try:
|
||||||
|
assert add_file(sid, 'exact.bin', b'x' * 10) is True
|
||||||
|
assert add_file(sid, 'overflow.bin', b'x') is False
|
||||||
|
assert len(get_files(sid)) == 1
|
||||||
|
finally:
|
||||||
|
configure(max_session_bytes=previous)
|
||||||
|
cleanup(sid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_refs_rejects_actual_oversize_and_deletes_buffer():
|
||||||
|
storage = {'oversize': b'123456789'}
|
||||||
|
deleted = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
if request.method == 'GET':
|
||||||
|
return httpx.Response(200, content=storage['oversize'])
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
deleted.append(request.url.path)
|
||||||
|
storage.clear()
|
||||||
|
return httpx.Response(204)
|
||||||
|
return httpx.Response(405)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
app = build_app(transport, maxFileBytes=8)
|
||||||
|
sid = create_session()
|
||||||
|
try:
|
||||||
|
with app.test_client() as client:
|
||||||
|
response = client.post('/api/upload_refs', json={
|
||||||
|
'session': sid,
|
||||||
|
'files': [{
|
||||||
|
'name': 'oversize.txt',
|
||||||
|
'size': 8,
|
||||||
|
'url': 'https://vm.test/buffer/oversize',
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['added'] == 0
|
||||||
|
assert get_files(sid) == []
|
||||||
|
assert deleted == ['/buffer/oversize']
|
||||||
|
assert storage == {}
|
||||||
|
finally:
|
||||||
|
cleanup(sid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_refs_retries_transient_pull_and_deletes_after_success():
|
||||||
|
attempts = []
|
||||||
|
deleted = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
if request.method == 'GET':
|
||||||
|
attempts.append(request.url.path)
|
||||||
|
if len(attempts) == 1:
|
||||||
|
return httpx.Response(503)
|
||||||
|
return httpx.Response(200, content=b'retry-content')
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
deleted.append(request.url.path)
|
||||||
|
return httpx.Response(204)
|
||||||
|
return httpx.Response(405)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
app = build_app(transport)
|
||||||
|
with app.test_client() as client:
|
||||||
|
response = client.post('/api/upload_refs', json={'files': [{
|
||||||
|
'name': 'retry.txt',
|
||||||
|
'size': 13,
|
||||||
|
'url': 'https://vm.test/buffer/retry',
|
||||||
|
}]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['added'] == 1
|
||||||
|
assert len(attempts) == 2
|
||||||
|
assert deleted == ['/buffer/retry']
|
||||||
|
cleanup(data['session'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_ttl_removes_session():
|
||||||
|
previous = session_state.TTL_SECONDS
|
||||||
|
configure(ttl_seconds=0.05)
|
||||||
|
sid = create_session()
|
||||||
|
try:
|
||||||
|
deadline = monotonic() + 1
|
||||||
|
while monotonic() < deadline and get_files(sid) is not None:
|
||||||
|
threading.Event().wait(0.01)
|
||||||
|
assert get_files(sid) is None
|
||||||
|
finally:
|
||||||
|
configure(ttl_seconds=previous)
|
||||||
|
cleanup(sid)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { render } from '../upload/frontend/table/render.js';
|
||||||
|
|
||||||
|
function elements() {
|
||||||
|
return { tableBodyEl: { innerHTML: '' }, countEl: { textContent: '' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileNode() {
|
||||||
|
return {
|
||||||
|
id: 'file-1',
|
||||||
|
kind: 'file',
|
||||||
|
name: 'document.txt',
|
||||||
|
path: 'folder/document.txt',
|
||||||
|
file: { size: 7 },
|
||||||
|
children: [],
|
||||||
|
expanded: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('render: папка по умолчанию скрывает дочерние файлы', () => {
|
||||||
|
const child = fileNode();
|
||||||
|
const state = {
|
||||||
|
nodes: [{
|
||||||
|
id: 'folder-1',
|
||||||
|
kind: 'folder',
|
||||||
|
name: 'folder',
|
||||||
|
path: 'folder',
|
||||||
|
children: [child],
|
||||||
|
expanded: false,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const output = elements();
|
||||||
|
|
||||||
|
render(state, output);
|
||||||
|
|
||||||
|
assert.match(output.tableBodyEl.innerHTML, /data-toggle="folder-1"/);
|
||||||
|
assert.match(output.tableBodyEl.innerHTML, /aria-expanded="false"/);
|
||||||
|
assert.doesNotMatch(output.tableBodyEl.innerHTML, /data-path="folder\/document\.txt"/);
|
||||||
|
assert.equal(output.countEl.textContent, '1 файлов · 7 B');
|
||||||
|
|
||||||
|
state.nodes[0].expanded = true;
|
||||||
|
render(state, output);
|
||||||
|
|
||||||
|
assert.match(output.tableBodyEl.innerHTML, /data-path="folder\/document\.txt"/);
|
||||||
|
assert.match(output.tableBodyEl.innerHTML, /aria-expanded="true"/);
|
||||||
|
});
|
||||||
@@ -231,3 +231,32 @@ test('uploadViaVM: прерывание через signal', async () => {
|
|||||||
assert.equal(res.ok, false);
|
assert.equal(res.ok, false);
|
||||||
assert.equal(res.aborted, true);
|
assert.equal(res.aborted, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('uploadViaVM: отмена после первого файла не начинает второй PUT', async () => {
|
||||||
|
installFetch(async (url, opts) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ok: true, session: 'partial-sid', count: 1 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const statuses = [];
|
||||||
|
const files = [new File(['first'], 'first.txt'), new File(['second'], 'second.txt')];
|
||||||
|
const p = uploadViaVM(files, {
|
||||||
|
vmUploadUrl: 'https://vm-buffer/upload/',
|
||||||
|
signal: ac.signal,
|
||||||
|
onFileStatus: (idx, status) => statuses.push({ idx, status }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
assert.equal(lastXHR.url.endsWith('_0'), true);
|
||||||
|
lastXHR.status = 201;
|
||||||
|
lastXHR.onload();
|
||||||
|
ac.abort();
|
||||||
|
const res = await p;
|
||||||
|
|
||||||
|
assert.equal(res.ok, false);
|
||||||
|
assert.equal(res.aborted, true);
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
assert.equal(lastXHR.url.endsWith('_0'), true);
|
||||||
|
assert.ok(statuses.some(({ idx, status }) => idx === 0 && status.includes('Доставлен')));
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function onFolderChange(state, cfg, elements) {
|
|||||||
if (!roots.has(rootName)) {
|
if (!roots.has(rootName)) {
|
||||||
// Один root на выбранный каталог позволяет сохранить дерево целиком.
|
// Один root на выбранный каталог позволяет сохранить дерево целиком.
|
||||||
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
||||||
path: rootName, children: [], expanded: true });
|
path: rootName, children: [], expanded: false });
|
||||||
}
|
}
|
||||||
const root = roots.get(rootName);
|
const root = roots.get(rootName);
|
||||||
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
|
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
|
||||||
@@ -44,7 +44,7 @@ export function onFolderChange(state, cfg, elements) {
|
|||||||
if (!child) {
|
if (!child) {
|
||||||
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
|
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
|
||||||
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
|
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
|
||||||
children: [], expanded: true };
|
children: [], expanded: false };
|
||||||
current.children.push(child);
|
current.children.push(child);
|
||||||
}
|
}
|
||||||
current = child;
|
current = child;
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ function safeEntryParts(entryName) {
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Создаёт единый узел file, folder или zip с уникальным id и раскрытым состоянием. */
|
/** Создаёт единый узел file, folder или zip с уникальным id и начальным состоянием раскрытия. */
|
||||||
function node(kind, name, path, children = [], file = null) {
|
function node(kind, name, path, children = [], file = null) {
|
||||||
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
|
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === 'file' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
|
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
|
||||||
|
|||||||
Reference in New Issue
Block a user