14 changed files with 283 additions and 27 deletions
@@ -0,0 +1,17 @@
# 2026-09-06: правило документирования и частых коммитов
## Подтверждённое правило работы
Пользователь явно потребовал:
- документировать все обнаруженные факты, решения и результаты сразу;
- делать отдельный коммит после каждой правки или логического изменения;
- не накапливать несколько независимых изменений до одного общего коммита.
## Результат текущего шага
- UX-правка таймера и пояснения загрузки зафиксирована коммитом `87de223`.
- Коммит `87de223` успешно отправлен в `origin/master`.
- На момент документирования локальная ветка `master` синхронизирована с `origin/master`.
Это правило применяется к дальнейшей работе в проекте upload-platform.
@@ -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`.
+9
View File
@@ -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 на мобильном экране и при медленном соединении.
>
> Весь текст ниже сохранён только как исторический план и не должен использоваться
> для новых изменений без отдельного пересмотра требований.
+3 -3
View File
@@ -501,7 +501,7 @@ function safeEntryParts(entryName) {
return parts;
}
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" };
}
function addPath(root, parts, fileNode) {
let current = root;
@@ -745,7 +745,7 @@ function onFolderChange(state, cfg, elements) {
name: rootName,
path: rootName,
children: [],
expanded: true
expanded: false
});
}
const root = roots.get(rootName);
@@ -762,7 +762,7 @@ function onFolderChange(state, cfg, elements) {
name: part,
path: `${rootName}/${nodeParts.slice(0, index + 1).join("/")}`,
children: [],
expanded: true
expanded: false
};
current.children.push(child);
}
+3 -3
View File
@@ -529,7 +529,7 @@ var FilePicker = (() => {
return parts;
}
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" };
}
function addPath(root, parts, fileNode) {
let current = root;
@@ -773,7 +773,7 @@ var FilePicker = (() => {
name: rootName,
path: rootName,
children: [],
expanded: true
expanded: false
});
}
const root = roots.get(rootName);
@@ -790,7 +790,7 @@ var FilePicker = (() => {
name: part,
path: `${rootName}/${nodeParts.slice(0, index + 1).join("/")}`,
children: [],
expanded: true
expanded: false
};
current.children.push(child);
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "upload-platform-file-picker",
"private": true,
"version": "0.2.0",
"version": "0.2.2",
"type": "module",
"scripts": {
"build": "node build.mjs",
+1 -1
View File
@@ -28,7 +28,7 @@ try:
except (OSError, json.JSONDecodeError):
CONFIG = DEFAULT_CONFIG
VERSION = "0.2.0"
VERSION = "0.2.2"
app = Flask(
__name__,
+3
View File
@@ -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; }
+79 -15
View File
@@ -30,6 +30,11 @@
</button>
<span id="upload-status" style="font: 14px sans-serif; color: #2d4a36;"></span>
</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;">
<strong>Сессия в RAM:</strong> <code id="session-id"></code>
<div id="session-files" style="margin-top: 6px; color: #46604e;"></div>
@@ -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 = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка загрузки</span>';
console.error('Upload failed unexpectedly', error);
return;
}
stopUploadTimer();
cancelBtn.style.display = 'none';
uploadBtn.disabled = selectedFiles.length === 0;
if (res.ok) {
showUploadPhase('Загрузка завершена', 'Все файлы доставлены в RAM сессии.');
uploadStatus.innerHTML = '<span style="color:#1f5a3b; font-weight: bold;">✓ Все ' + res.count + ' файл(ов) успешно доставлены в RAM бэкенда!</span>';
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 = '<span style="color:#8c5148;">Загрузка отменена пользователем</span>';
} else {
showUploadPhase('Загрузка остановлена', 'Проверьте сообщение об ошибке. Уже доставленные файлы остаются в RAM сессии.');
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка: ' + res.error + '</span>';
}
});
+48
View File
@@ -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"/);
});
+2 -2
View File
@@ -31,7 +31,7 @@ export function onFolderChange(state, cfg, elements) {
if (!roots.has(rootName)) {
// Один root на выбранный каталог позволяет сохранить дерево целиком.
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);
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
@@ -44,7 +44,7 @@ export function onFolderChange(state, cfg, elements) {
if (!child) {
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
children: [], expanded: true };
children: [], expanded: false };
current.children.push(child);
}
current = child;
+2 -2
View File
@@ -36,9 +36,9 @@ function safeEntryParts(entryName) {
return parts;
}
/** Создаёт единый узел file, folder или zip с уникальным id и раскрытым состоянием. */
/** Создаёт единый узел file, folder или zip с уникальным id и начальным состоянием раскрытия. */
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-узлы. */