diff --git a/site/app.py b/site/app.py
index 632bf07..8155cd4 100644
--- a/site/app.py
+++ b/site/app.py
@@ -1,11 +1,33 @@
-from flask import Flask, render_template
+import time
+from flask import Flask, render_template, request
app = Flask(__name__)
+VERSION = '1.0.0'
+
@app.route('/')
def index():
- return render_template('index.html')
+ return render_template('index.html', version=VERSION)
+
+
+@app.route('/health')
+def health():
+ return {'ok': True, 'version': VERSION}
+
+
+@app.route('/upload', methods=['POST'])
+def upload():
+ t0 = time.time()
+ total = 0
+ for f in request.files.values():
+ while True:
+ chunk = f.read(8192)
+ if not chunk:
+ break
+ total += len(chunk)
+ elapsed = round((time.time() - t0) * 1000)
+ return {'ok': True, 'bytes': total, 'elapsed_ms': elapsed}
if __name__ == '__main__':
diff --git a/site/static/app.js b/site/static/app.js
index fd1cb58..e391e23 100644
--- a/site/static/app.js
+++ b/site/static/app.js
@@ -1,18 +1,19 @@
/**
- * app.js — UI выбора файлов из локали.
+ * app.js — UI выбора и загрузки файлов.
*
* Выбираем файлы через ,
- * показываем в таблице: имя, размер, кнопка удаления.
- *
- * Никакой загрузки на сервер — чисто клиент.
+ * показываем в таблице: имя, размер, прогресс, статус.
+ * Кнопка «Загрузить» — XHR на /upload, контент не сохраняется.
*/
-var files = []; // [{ name, size, lastModified }]
+var files = []; // [{ name, size, lastModified, file, status, progress, speed, elapsed }]
+var uploading = false; // флаг: идёт загрузка
var fileInput = document.getElementById('fileInput');
var fileTable = document.getElementById('fileTable');
var fileSummary = document.getElementById('fileSummary');
var clearBtn = document.getElementById('clearBtn');
+var uploadBtn = document.getElementById('uploadBtn');
fileInput.addEventListener('change', function () {
addFiles(fileInput.files);
@@ -28,6 +29,14 @@ function formatSize(bytes) {
return (bytes / 1073741824).toFixed(2) + ' GB';
}
+/** formatSpeed(bytesPerSec) → "1.2 MB/s" */
+function formatSpeed(bps) {
+ if (!bps || bps === 0) return '—';
+ if (bps < 1024) return bps + ' B/s';
+ if (bps < 1048576) return (bps / 1024).toFixed(1) + ' KB/s';
+ return (bps / 1048576).toFixed(1) + ' MB/s';
+}
+
/** addFiles(fileList) — добавить файлы в массив и перерисовать */
function addFiles(fileList) {
for (var i = 0; i < fileList.length; i++) {
@@ -35,7 +44,12 @@ function addFiles(fileList) {
files.push({
name: f.name,
size: f.size,
- lastModified: f.lastModified
+ lastModified: f.lastModified,
+ file: f, // сохраняем File-объект для XHR
+ status: '', // '' | 'uploading' | 'ok' | 'err'
+ progress: 0, // 0-100
+ speed: null, // bytes/sec
+ elapsed: null // ms
});
}
renderTable();
@@ -43,6 +57,7 @@ function addFiles(fileList) {
/** removeFile(i) — удалить файл по индексу */
function removeFile(i) {
+ if (uploading) return;
files.splice(i, 1);
renderTable();
}
@@ -50,9 +65,10 @@ function removeFile(i) {
/** renderTable() — перерисовать таблицу и сводку */
function renderTable() {
if (files.length === 0) {
- fileTable.innerHTML = '
| Нет файлов — выберите файлы |
';
+ fileTable.innerHTML = '| Нет файлов — выберите файлы |
';
fileSummary.textContent = 'Нет файлов';
clearBtn.disabled = true;
+ uploadBtn.disabled = true;
return;
}
@@ -61,22 +77,157 @@ function renderTable() {
for (var i = 0; i < files.length; i++) {
var f = files[i];
totalSize += f.size;
+
+ // Прогресс-бар
+ var progressHTML = '';
+ if (f.status === 'uploading') {
+ progressHTML = '' +
+ '
' +
+ '
' + f.progress + '%' +
+ '
';
+ } else if (f.status === 'ok') {
+ progressHTML = '✓ ' + formatSpeed(f.speed) + '';
+ } else if (f.status === 'err') {
+ progressHTML = '✗';
+ } else {
+ progressHTML = '—';
+ }
+
+ // Статус
+ var statusHTML = '';
+ if (f.status === 'uploading') {
+ statusHTML = '↑ загрузка...';
+ } else if (f.status === 'ok') {
+ statusHTML = '✓ ' + (f.elapsed ? (f.elapsed / 1000).toFixed(1) + 'с' : '') + '';
+ } else if (f.status === 'err') {
+ statusHTML = '✗ ошибка';
+ } else {
+ statusHTML = 'ожидает';
+ }
+
rows.push(
'' +
'| ' + escHtml(f.name) + ' | ' +
'' + formatSize(f.size) + ' | ' +
- ' | ' +
+ '' + progressHTML + ' | ' +
+ '' + statusHTML + ' | ' +
+ ' | ' +
'
'
);
}
fileTable.innerHTML = rows.join('');
fileSummary.textContent = files.length + ' файл., ' + formatSize(totalSize);
- clearBtn.disabled = false;
+ clearBtn.disabled = uploading;
+ uploadBtn.disabled = uploading || files.length === 0;
+
+ if (uploading) {
+ uploadBtn.textContent = '⏳ Загрузка...';
+ } else {
+ uploadBtn.textContent = 'Загрузить';
+ }
+}
+
+/** uploadAll() — последовательная загрузка всех файлов */
+async function uploadAll() {
+ if (uploading || files.length === 0) return;
+ uploading = true;
+ uploadBtn.disabled = true;
+ clearBtn.disabled = true;
+
+ var totalBytes = 0;
+ var totalElapsed = 0;
+ var okCount = 0;
+ var errCount = 0;
+
+ for (var i = 0; i < files.length; i++) {
+ var f = files[i];
+ if (f.status === 'ok') continue; // уже загружен — пропускаем
+
+ f.status = 'uploading';
+ f.progress = 0;
+ renderTable();
+
+ try {
+ var result = await uploadOne(f, i);
+ f.status = 'ok';
+ f.elapsed = result.elapsed_ms;
+ f.speed = result.elapsed_ms > 0 ? Math.round(result.bytes / (result.elapsed_ms / 1000)) : null;
+ totalBytes += result.bytes;
+ totalElapsed += result.elapsed_ms;
+ okCount++;
+ } catch (e) {
+ f.status = 'err';
+ f.error = e.message;
+ errCount++;
+ }
+ renderTable();
+ }
+
+ uploading = false;
+ renderTable();
+
+ // Сводка в fileSummary
+ var summary = files.length + ' файл.';
+ if (okCount > 0) {
+ var avgSpeed = totalElapsed > 0 ? Math.round(totalBytes / (totalElapsed / 1000)) : 0;
+ summary += ' | ✓ ' + okCount + ' / ✗ ' + errCount;
+ summary += ' | ' + formatSize(totalBytes) + ' за ' + (totalElapsed / 1000).toFixed(1) + 'с';
+ summary += ' | ~' + formatSpeed(avgSpeed);
+ }
+ fileSummary.textContent = summary;
+}
+
+/** uploadOne(fileEntry, idx) — XHR одного файла, возвращает {bytes, elapsed_ms} */
+function uploadOne(f, idx) {
+ return new Promise(function (resolve, reject) {
+ var xhr = new XMLHttpRequest();
+ var fd = new FormData();
+ fd.append('file', f.file, f.name);
+
+ var t0 = Date.now();
+
+ xhr.open('POST', '/upload');
+
+ xhr.upload.onprogress = function (e) {
+ if (e.lengthComputable) {
+ f.progress = Math.round(e.loaded / e.total * 100);
+ renderTable();
+ }
+ };
+
+ xhr.onload = function () {
+ try {
+ var r = JSON.parse(xhr.responseText);
+ if (r.ok) {
+ resolve({ bytes: r.bytes, elapsed_ms: r.elapsed_ms });
+ } else {
+ reject(new Error(r.error || 'Ошибка сервера'));
+ }
+ } catch (e) {
+ reject(new Error('Некорректный ответ'));
+ }
+ };
+
+ xhr.onerror = function () {
+ var elapsed = Date.now() - t0;
+ reject(new Error('Сеть (' + (elapsed / 1000).toFixed(0) + 'с)'));
+ };
+
+ xhr.ontimeout = function () {
+ reject(new Error('Таймаут (' + (xhr.timeout / 1000) + 'с)'));
+ };
+
+ xhr.timeout = 300000; // 5 минут на файл
+ xhr.send(fd);
+ });
}
/** clearAll() — очистить всё */
function clearAll() {
+ if (uploading) return;
files = [];
renderTable();
}
diff --git a/site/templates/index.html b/site/templates/index.html
index 9046b74..05c279a 100644
--- a/site/templates/index.html
+++ b/site/templates/index.html
@@ -81,6 +81,7 @@
📦 Loadtest — загрузка файлов
+ v{{ version }}
@@ -95,7 +96,9 @@
| Имя |
- Размер |
+ Размер |
+ Прогресс |
+ Статус |
✕ |
@@ -104,7 +107,10 @@