feat: /upload эндпоинт, кнопка Загрузить, прогресс-бары, скорость, версия v1.0.0
Deploy loadtest / validate (push) Waiting to run
Deploy loadtest / validate (push) Waiting to run
This commit is contained in:
+24
-2
@@ -1,11 +1,33 @@
|
|||||||
from flask import Flask, render_template
|
import time
|
||||||
|
from flask import Flask, render_template, request
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
VERSION = '1.0.0'
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+160
-9
@@ -1,18 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* app.js — UI выбора файлов из локали.
|
* app.js — UI выбора и загрузки файлов.
|
||||||
*
|
*
|
||||||
* Выбираем файлы через <input type="file" multiple>,
|
* Выбираем файлы через <input type="file" multiple>,
|
||||||
* показываем в таблице: имя, размер, кнопка удаления.
|
* показываем в таблице: имя, размер, прогресс, статус.
|
||||||
*
|
* Кнопка «Загрузить» — 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 fileInput = document.getElementById('fileInput');
|
||||||
var fileTable = document.getElementById('fileTable');
|
var fileTable = document.getElementById('fileTable');
|
||||||
var fileSummary = document.getElementById('fileSummary');
|
var fileSummary = document.getElementById('fileSummary');
|
||||||
var clearBtn = document.getElementById('clearBtn');
|
var clearBtn = document.getElementById('clearBtn');
|
||||||
|
var uploadBtn = document.getElementById('uploadBtn');
|
||||||
|
|
||||||
fileInput.addEventListener('change', function () {
|
fileInput.addEventListener('change', function () {
|
||||||
addFiles(fileInput.files);
|
addFiles(fileInput.files);
|
||||||
@@ -28,6 +29,14 @@ function formatSize(bytes) {
|
|||||||
return (bytes / 1073741824).toFixed(2) + ' GB';
|
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) — добавить файлы в массив и перерисовать */
|
/** addFiles(fileList) — добавить файлы в массив и перерисовать */
|
||||||
function addFiles(fileList) {
|
function addFiles(fileList) {
|
||||||
for (var i = 0; i < fileList.length; i++) {
|
for (var i = 0; i < fileList.length; i++) {
|
||||||
@@ -35,7 +44,12 @@ function addFiles(fileList) {
|
|||||||
files.push({
|
files.push({
|
||||||
name: f.name,
|
name: f.name,
|
||||||
size: f.size,
|
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();
|
renderTable();
|
||||||
@@ -43,6 +57,7 @@ function addFiles(fileList) {
|
|||||||
|
|
||||||
/** removeFile(i) — удалить файл по индексу */
|
/** removeFile(i) — удалить файл по индексу */
|
||||||
function removeFile(i) {
|
function removeFile(i) {
|
||||||
|
if (uploading) return;
|
||||||
files.splice(i, 1);
|
files.splice(i, 1);
|
||||||
renderTable();
|
renderTable();
|
||||||
}
|
}
|
||||||
@@ -50,9 +65,10 @@ function removeFile(i) {
|
|||||||
/** renderTable() — перерисовать таблицу и сводку */
|
/** renderTable() — перерисовать таблицу и сводку */
|
||||||
function renderTable() {
|
function renderTable() {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="3">Нет файлов — выберите файлы</td></tr>';
|
fileTable.innerHTML = '<tr class="empty-row"><td colspan="5">Нет файлов — выберите файлы</td></tr>';
|
||||||
fileSummary.textContent = 'Нет файлов';
|
fileSummary.textContent = 'Нет файлов';
|
||||||
clearBtn.disabled = true;
|
clearBtn.disabled = true;
|
||||||
|
uploadBtn.disabled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,22 +77,157 @@ function renderTable() {
|
|||||||
for (var i = 0; i < files.length; i++) {
|
for (var i = 0; i < files.length; i++) {
|
||||||
var f = files[i];
|
var f = files[i];
|
||||||
totalSize += f.size;
|
totalSize += f.size;
|
||||||
|
|
||||||
|
// Прогресс-бар
|
||||||
|
var progressHTML = '';
|
||||||
|
if (f.status === 'uploading') {
|
||||||
|
progressHTML = '<div style="display:flex;align-items:center;gap:4px;">' +
|
||||||
|
'<div style="flex:1;height:6px;background:#e0e0e0;border-radius:3px;overflow:hidden;">' +
|
||||||
|
'<div style="height:100%;width:' + f.progress + '%;background:var(--brand);border-radius:3px;transition:width .1s;"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<span style="font-size:11px;min-width:32px;">' + f.progress + '%</span>' +
|
||||||
|
'</div>';
|
||||||
|
} else if (f.status === 'ok') {
|
||||||
|
progressHTML = '<span style="color:var(--green);font-size:12px;">✓ ' + formatSpeed(f.speed) + '</span>';
|
||||||
|
} else if (f.status === 'err') {
|
||||||
|
progressHTML = '<span style="color:var(--red);font-size:12px;">✗</span>';
|
||||||
|
} else {
|
||||||
|
progressHTML = '<span style="color:var(--muted);">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус
|
||||||
|
var statusHTML = '';
|
||||||
|
if (f.status === 'uploading') {
|
||||||
|
statusHTML = '<span style="color:var(--brand);">↑ загрузка...</span>';
|
||||||
|
} else if (f.status === 'ok') {
|
||||||
|
statusHTML = '<span style="color:var(--green);">✓ ' + (f.elapsed ? (f.elapsed / 1000).toFixed(1) + 'с' : '') + '</span>';
|
||||||
|
} else if (f.status === 'err') {
|
||||||
|
statusHTML = '<span style="color:var(--red);" title="' + escHtml(f.error || '') + '">✗ ошибка</span>';
|
||||||
|
} else {
|
||||||
|
statusHTML = '<span style="color:var(--muted);">ожидает</span>';
|
||||||
|
}
|
||||||
|
|
||||||
rows.push(
|
rows.push(
|
||||||
'<tr>' +
|
'<tr>' +
|
||||||
'<td class="name-cell" title="' + escHtml(f.name) + '">' + escHtml(f.name) + '</td>' +
|
'<td class="name-cell" title="' + escHtml(f.name) + '">' + escHtml(f.name) + '</td>' +
|
||||||
'<td class="num-cell">' + formatSize(f.size) + '</td>' +
|
'<td class="num-cell">' + formatSize(f.size) + '</td>' +
|
||||||
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить">✕</button></td>' +
|
'<td>' + progressHTML + '</td>' +
|
||||||
|
'<td>' + statusHTML + '</td>' +
|
||||||
|
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"' + (uploading ? ' disabled style="opacity:.3;cursor:not-allowed;"' : '') + '>✕</button></td>' +
|
||||||
'</tr>'
|
'</tr>'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fileTable.innerHTML = rows.join('');
|
fileTable.innerHTML = rows.join('');
|
||||||
|
|
||||||
fileSummary.textContent = files.length + ' файл., ' + formatSize(totalSize);
|
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() — очистить всё */
|
/** clearAll() — очистить всё */
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
|
if (uploading) return;
|
||||||
files = [];
|
files = [];
|
||||||
renderTable();
|
renderTable();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
📦 Loadtest — загрузка файлов
|
📦 Loadtest — загрузка файлов
|
||||||
|
<span style="font-weight:400;color:var(--muted);font-size:12px;margin-left:auto;">v{{ version }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@@ -95,7 +96,9 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Имя</th>
|
<th>Имя</th>
|
||||||
<th style="width:100px;">Размер</th>
|
<th style="width:90px;">Размер</th>
|
||||||
|
<th style="width:100px;">Прогресс</th>
|
||||||
|
<th style="width:90px;">Статус</th>
|
||||||
<th style="width:40px;">✕</th>
|
<th style="width:40px;">✕</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -104,7 +107,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer-bar">
|
<div class="footer-bar">
|
||||||
<span id="fileSummary">Нет файлов</span>
|
<span id="fileSummary">Нет файлов</span>
|
||||||
<button class="btn" id="clearBtn" onclick="clearAll()" disabled>Очистить</button>
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button class="btn btn-primary" id="uploadBtn" onclick="uploadAll()" disabled>Загрузить</button>
|
||||||
|
<button class="btn" id="clearBtn" onclick="clearAll()" disabled>Очистить</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user