Add upload wait timer and progress explanation
This commit is contained in:
@@ -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`.
|
||||||
@@ -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 на мобильном экране и при медленном соединении.
|
||||||
>
|
>
|
||||||
> Весь текст ниже сохранён только как исторический план и не должен использоваться
|
> Весь текст ниже сохранён только как исторический план и не должен использоваться
|
||||||
> для новых изменений без отдельного пересмотра требований.
|
> для новых изменений без отдельного пересмотра требований.
|
||||||
|
|||||||
+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.1",
|
||||||
"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.1"
|
||||||
|
|
||||||
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>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user