diff --git a/HISTORY/2026-09-06-layer2-ux-first-file-timer.md b/HISTORY/2026-09-06-layer2-ux-first-file-timer.md
new file mode 100644
index 0000000..fefa720
--- /dev/null
+++ b/HISTORY/2026-09-06-layer2-ux-first-file-timer.md
@@ -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`.
diff --git a/PLAN.md b/PLAN.md
index a2415cb..6758a7f 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -9,6 +9,15 @@
> Актуальное описание: [README.md](README.md) и
> [upload/README.md](upload/README.md). История перехода на picker-only и
> исправлений находится в [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 на мобильном экране и при медленном соединении.
>
> Весь текст ниже сохранён только как исторический план и не должен использоваться
> для новых изменений без отдельного пересмотра требований.
diff --git a/package.json b/package.json
index 6ee2367..0b678f5 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "upload-platform-file-picker",
"private": true,
- "version": "0.2.0",
+ "version": "0.2.1",
"type": "module",
"scripts": {
"build": "node build.mjs",
diff --git a/site/app.py b/site/app.py
index 0103b07..be21ce1 100644
--- a/site/app.py
+++ b/site/app.py
@@ -28,7 +28,7 @@ try:
except (OSError, json.JSONDecodeError):
CONFIG = DEFAULT_CONFIG
-VERSION = "0.2.0"
+VERSION = "0.2.1"
app = Flask(
__name__,
diff --git a/site/static/style.css b/site/static/style.css
index 861fbfc..7cdd352 100644
--- a/site/static/style.css
+++ b/site/static/style.css
@@ -28,6 +28,9 @@ button.quiet { color: #5d6a61; background: transparent; }
button:disabled { opacity: .45; cursor: not-allowed; }
/* Резервная область сообщения интегратора сохраняет высоту и не двигает таблицу. */
.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 на узких экранах не ломает таблицу дерева. */
.table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
diff --git a/site/templates/index.html b/site/templates/index.html
index 9c4a2db..9606108 100644
--- a/site/templates/index.html
+++ b/site/templates/index.html
@@ -30,6 +30,11 @@
+
+ Подготовка загрузки
+ Первое соединение может занять время: устанавливаются соединения с буфером ВМ и сервисом.
+ Прошло: 00:00
+
Сессия в RAM:
@@ -45,9 +50,41 @@
const sessionInfo = document.getElementById('session-info');
const sessionIdEl = document.getElementById('session-id');
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 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({
mount: '#file-picker',
@@ -67,30 +104,55 @@
cancelBtn.style.display = 'inline-block';
sessionInfo.style.display = 'none';
abortCtrl = new AbortController();
+ startUploadTimer();
+ showUploadPhase(
+ 'Первый файл: устанавливаем соединение',
+ 'Это не зависание. Сначала файл отправляется в буфер ВМ, затем сервис забирает его в RAM и очищает буфер.'
+ );
const vmUploadUrl = new URL({{ config.vmUploadUrl|tojson }}, window.location.origin).href;
- const res = await FilePicker.uploadViaVM(files, {
- vmUploadUrl: vmUploadUrl,
- backendUploadUrl: '/api/upload_refs',
- signal: abortCtrl.signal,
- onStatus: (msg) => { uploadStatus.textContent = msg; },
- 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;
+ let res;
+ try {
+ res = await FilePicker.uploadViaVM(files, {
+ vmUploadUrl: vmUploadUrl,
+ backendUploadUrl: '/api/upload_refs',
+ signal: abortCtrl.signal,
+ onStatus: (msg) => {
+ uploadStatus.textContent = msg;
+ if (msg.startsWith('Загрузка в буфер')) {
+ 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) => {
- console.log('File delivered to RAM:', info);
- }
- });
+ });
+ } catch (error) {
+ stopUploadTimer();
+ cancelBtn.style.display = 'none';
+ uploadBtn.disabled = selectedFiles.length === 0;
+ showUploadPhase('Загрузка остановлена', 'Произошла непредвиденная ошибка. Повторите загрузку.');
+ uploadStatus.innerHTML = '
✗ Ошибка загрузки';
+ console.error('Upload failed unexpectedly', error);
+ return;
+ }
+ stopUploadTimer();
cancelBtn.style.display = 'none';
uploadBtn.disabled = selectedFiles.length === 0;
if (res.ok) {
+ showUploadPhase('Загрузка завершена', 'Все файлы доставлены в RAM сессии.');
uploadStatus.innerHTML = '
✓ Все ' + res.count + ' файл(ов) успешно доставлены в RAM бэкенда!';
sessionIdEl.textContent = res.session;
sessionInfo.style.display = 'block';
@@ -106,8 +168,10 @@
console.error('Failed to load session files', e);
}
} else if (res.aborted) {
+ showUploadPhase('Загрузка отменена', 'Переданные до отмены файлы уже находятся в RAM сессии.');
uploadStatus.innerHTML = '
Загрузка отменена пользователем';
} else {
+ showUploadPhase('Загрузка остановлена', 'Проверьте сообщение об ошибке. Уже доставленные файлы остаются в RAM сессии.');
uploadStatus.innerHTML = '
✗ Ошибка: ' + res.error + '';
}
});