LoadTest: проверка загрузки файлов (Flask, site/app.py)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# Зависимости LoadTest-сервиса (платформа Nubes читает этот файл из корня репо).
|
||||||
|
Flask>=3.0
|
||||||
|
gunicorn>=21.2
|
||||||
|
requests>=2.31
|
||||||
Binary file not shown.
+92
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
LoadTest - сервис проверки загрузки файлов на платформу Nubes (Flask).
|
||||||
|
|
||||||
|
ЦЕЛЬ: проверить, доходят ли файлы (в т.ч. большие) до бэкенда или нет.
|
||||||
|
Файл принимается в ПАМЯТЬ (без записи на диск) и возвращается МАКСИМУМ статистики.
|
||||||
|
|
||||||
|
Обязательная структура для платформы Nubes:
|
||||||
|
repo/
|
||||||
|
├── requirements.txt
|
||||||
|
└── site/
|
||||||
|
├── app.py <- этот файл (точка входа: python site/app.py)
|
||||||
|
├── static/style.css
|
||||||
|
└── templates/index.html
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, render_template, request
|
||||||
|
|
||||||
|
# Явно указываем папки шаблонов и статики -
|
||||||
|
# платформа запускает `python site/app.py` из корня репо, иначе Flask не найдёт шаблоны.
|
||||||
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
|
||||||
|
# ВАЖНО: НЕ ставим MAX_CONTENT_LENGTH (по умолчанию лимита нет).
|
||||||
|
# Цель - проверить РЕАЛЬНЫЕ лимиты платформы/прокси, а не собственный лимит Flask.
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
"""Главная страница - форма выбора файла и загрузки."""
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
|
def health():
|
||||||
|
"""Проверка живости для платформы."""
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/upload", methods=["POST"])
|
||||||
|
def upload():
|
||||||
|
"""Приём файла в память + сбор максимальной статистики."""
|
||||||
|
t0 = time.time() # момент начала обработки запроса на сервере
|
||||||
|
|
||||||
|
# Метаданные запроса (заголовки, адрес клиента) - для диагностики.
|
||||||
|
meta = {
|
||||||
|
"server_start": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"remote_addr": request.remote_addr or "",
|
||||||
|
"content_length_header": request.headers.get("Content-Length", ""),
|
||||||
|
"content_type_header": request.headers.get("Content-Type", ""),
|
||||||
|
"user_agent": request.headers.get("User-Agent", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Валидация: должен прийти multipart-файл в поле "file".
|
||||||
|
if "file" not in request.files:
|
||||||
|
return jsonify({"ok": False, "error": "Нет поля 'file' в запросе", "meta": meta}), 400
|
||||||
|
|
||||||
|
f = request.files["file"]
|
||||||
|
if not f or f.filename == "":
|
||||||
|
return jsonify({"ok": False, "error": "Пустое имя файла", "meta": meta}), 400
|
||||||
|
|
||||||
|
# Читаем ВЕСЬ файл в память и замеряем время чтения.
|
||||||
|
t_read = time.time()
|
||||||
|
data = f.read()
|
||||||
|
read_s = time.time() - t_read
|
||||||
|
|
||||||
|
size = len(data) # сколько байт РЕАЛЬНО дошло до сервера
|
||||||
|
total_s = time.time() - t0 # полное время обработки запроса на сервере
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"file": {
|
||||||
|
"name": f.filename,
|
||||||
|
"mime": f.mimetype or "",
|
||||||
|
"size_bytes": size,
|
||||||
|
"size_mb": round(size / (1024 * 1024), 3),
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"read_ms": round(read_s * 1000, 2), # время чтения в память
|
||||||
|
"total_ms": round(total_s * 1000, 2), # полное время обработки
|
||||||
|
"speed_mb_s": round(size / (1024 * 1024) / read_s, 3) if read_s > 0 else 0,
|
||||||
|
},
|
||||||
|
"md5": hashlib.md5(data).hexdigest(), # контрольная сумма принятых байт
|
||||||
|
"meta": meta,
|
||||||
|
}
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# ОБЯЗАТЕЛЬНО для платформы: `python site/app.py` должен стартовать сервер.
|
||||||
|
# debug=False - прод-режим, без reloader (иначе поднимается второй процесс).
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* LoadTest - простые стили страницы проверки загрузки файлов. */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
background: #f4f6f8;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Панель выбора файла */
|
||||||
|
.panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel label {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel input[type="file"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel button {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel button:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Прогресс загрузки */
|
||||||
|
.progress-wrap {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 18px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 9px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: #22c55e;
|
||||||
|
transition: width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Итоговый статус */
|
||||||
|
.status {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Блок статистики */
|
||||||
|
#result {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- LoadTest - страница проверки загрузки файлов. -->
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LoadTest - проверка загрузки файлов</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>LoadTest - проверка загрузки файлов</h1>
|
||||||
|
<p class="hint">
|
||||||
|
Выберите ОДИН файл (в т.ч. большой) и нажмите «Загрузить».
|
||||||
|
Файл читается в память Flask (на диск не пишется). Результат: файл дошёл или нет + статистика.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Панель выбора файла и кнопка загрузки -->
|
||||||
|
<div class="panel">
|
||||||
|
<label for="file">Файл:</label>
|
||||||
|
<input type="file" id="file">
|
||||||
|
<button id="btn" onclick="startUpload()">Загрузить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Индикатор прогресса: полоса, % и таймер -->
|
||||||
|
<div class="progress-wrap" id="progressWrap" style="display:none">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-info">
|
||||||
|
<span id="progressPct">0%</span>
|
||||||
|
<span id="progressTimer">0с</span>
|
||||||
|
<span id="progressSpeed"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Итоговый статус: УСПЕХ / ОШИБКА -->
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<!-- Максимум статистики (клиент + сервер) -->
|
||||||
|
<pre id="result"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ---------- Загрузка одного файла через XMLHttpRequest ----------
|
||||||
|
// XHR даёт реальный прогресс отправки (fetch - не даёт), поэтому берём XHR.
|
||||||
|
|
||||||
|
function startUpload() {
|
||||||
|
var input = document.getElementById('file');
|
||||||
|
var file = input.files[0];
|
||||||
|
|
||||||
|
// Сброс старых данных.
|
||||||
|
var result = document.getElementById('result');
|
||||||
|
var status = document.getElementById('status');
|
||||||
|
var wrap = document.getElementById('progressWrap');
|
||||||
|
result.textContent = '';
|
||||||
|
status.textContent = '';
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
status.textContent = 'ОШИБКА: выберите файл';
|
||||||
|
status.className = 'status error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Статистика клиента ----
|
||||||
|
var startMs = Date.now(); // время начала загрузки (клиент)
|
||||||
|
var fileSize = file.size; // размер файла (байт)
|
||||||
|
var fileSizeMB = fileSize / (1024 * 1024);
|
||||||
|
var startLocal = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
// ---- Прогресс: полоса + % ----
|
||||||
|
wrap.style.display = 'block';
|
||||||
|
var fill = document.getElementById('progressFill');
|
||||||
|
var pctEl = document.getElementById('progressPct');
|
||||||
|
var timerEl = document.getElementById('progressTimer');
|
||||||
|
var speedEl = document.getElementById('progressSpeed');
|
||||||
|
fill.style.width = '0%';
|
||||||
|
pctEl.textContent = '0%';
|
||||||
|
timerEl.textContent = '0с';
|
||||||
|
speedEl.textContent = '';
|
||||||
|
|
||||||
|
// Таймер (второстепенно): обновляем прошедшие секунды каждую 1с.
|
||||||
|
var timer = setInterval(function () {
|
||||||
|
var sec = Math.floor((Date.now() - startMs) / 1000);
|
||||||
|
timerEl.textContent = sec + 'с';
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// ---- Формируем multipart-запрос ----
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('file', file, file.name); // поле должно называться 'file' (см. app.py)
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/upload');
|
||||||
|
// Таймаут 10 минут - большие файлы могут ехать долго.
|
||||||
|
xhr.timeout = 600000;
|
||||||
|
|
||||||
|
// Реальный прогресс отправки тела запроса (loaded - отправлено байт).
|
||||||
|
xhr.upload.onprogress = function (e) {
|
||||||
|
if (!e.lengthComputable) {
|
||||||
|
pctEl.textContent = '?%';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var pct = Math.round(e.loaded / e.total * 100);
|
||||||
|
fill.style.width = pct + '%';
|
||||||
|
pctEl.textContent = pct + '%';
|
||||||
|
// Мгновенная средняя скорость клиента.
|
||||||
|
var sec = (Date.now() - startMs) / 1000;
|
||||||
|
if (sec > 0) {
|
||||||
|
var mbps = (e.loaded / (1024 * 1024)) / sec;
|
||||||
|
speedEl.textContent = mbps.toFixed(2) + ' МБ/с';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Ответ сервера получен ----
|
||||||
|
xhr.onload = function () {
|
||||||
|
clearInterval(timer);
|
||||||
|
var sec = ((Date.now() - startMs) / 1000).toFixed(2);
|
||||||
|
var httpStatus = xhr.status;
|
||||||
|
var ok = xhr.status >= 200 && xhr.status < 300;
|
||||||
|
|
||||||
|
// Сервер может ответить и НЕ-JSON (413, HTML ошибки прокси) - показываем текст.
|
||||||
|
var data = null;
|
||||||
|
try { data = JSON.parse(xhr.responseText); } catch (e) { data = null; }
|
||||||
|
|
||||||
|
if (ok && data && data.ok) {
|
||||||
|
// УСПЕХ - файл дошёл до бэкенда.
|
||||||
|
status.textContent = '✅ ФАЙЛ ЗАГРУЖЕН';
|
||||||
|
status.className = 'status ok';
|
||||||
|
renderStats(file, fileSize, fileSizeMB, startLocal, sec, httpStatus, data);
|
||||||
|
} else {
|
||||||
|
// ОШИБКА - показываем статус, текст сервера и клиентскую статистику.
|
||||||
|
status.textContent = '❌ ФАЙЛ НЕ ЗАГРУЖЕН (HTTP ' + httpStatus + ')';
|
||||||
|
status.className = 'status error';
|
||||||
|
var errBody = data && data.error ? 'ошибка: ' + data.error : xhr.responseText;
|
||||||
|
result.textContent =
|
||||||
|
'HTTP статус: ' + httpStatus + '\n' +
|
||||||
|
'Ответ сервера: ' + (errBody || '(пусто)') + '\n' +
|
||||||
|
clientStats(file, fileSize, fileSizeMB, startLocal, sec);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Сеть: соединение оборвано, нет ответа ----
|
||||||
|
xhr.onerror = function () {
|
||||||
|
clearInterval(timer);
|
||||||
|
var sec = ((Date.now() - startMs) / 1000).toFixed(2);
|
||||||
|
status.textContent = '❌ СЕТЕВАЯ ОШИБКА - ответа нет';
|
||||||
|
status.className = 'status error';
|
||||||
|
result.textContent = clientStats(file, fileSize, fileSizeMB, startLocal, sec) +
|
||||||
|
'\n(соединение прервано, прокси/платформа не ответила)';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Таймаут ----
|
||||||
|
xhr.ontimeout = function () {
|
||||||
|
clearInterval(timer);
|
||||||
|
var sec = ((Date.now() - startMs) / 1000).toFixed(2);
|
||||||
|
status.textContent = '❌ ТАЙМАУТ (10 мин) - файл не загружен';
|
||||||
|
status.className = 'status error';
|
||||||
|
result.textContent = clientStats(file, fileSize, fileSizeMB, startLocal, sec);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Клиентская статистика (общая для всех исходов) ----
|
||||||
|
function clientStats(file, size, sizeMB, startLocal, sec) {
|
||||||
|
var avg = sec > 0 ? (size / (1024 * 1024) / sec).toFixed(2) : '0';
|
||||||
|
return '-- КЛИЕНТ --\n' +
|
||||||
|
'Файл: ' + file.name + '\n' +
|
||||||
|
'Размер: ' + size + ' байт (' + sizeMB.toFixed(3) + ' МБ)\n' +
|
||||||
|
'Начало: ' + startLocal + '\n' +
|
||||||
|
'Длительность: ' + sec + ' с\n' +
|
||||||
|
'Средняя скорость: ' + avg + ' МБ/с\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Полная статистика при успехе: клиент + сервер ----
|
||||||
|
function renderStats(file, size, sizeMB, startLocal, sec, httpStatus, data) {
|
||||||
|
var f = data.file;
|
||||||
|
var s = data.server;
|
||||||
|
var m = data.meta;
|
||||||
|
var avg = sec > 0 ? (size / (1024 * 1024) / sec).toFixed(2) : '0';
|
||||||
|
var out = '';
|
||||||
|
|
||||||
|
out += '-- КЛИЕНТ --\n' +
|
||||||
|
'Файл: ' + file.name + '\n' +
|
||||||
|
'Размер: ' + size + ' байт (' + sizeMB.toFixed(3) + ' МБ)\n' +
|
||||||
|
'Начало: ' + startLocal + '\n' +
|
||||||
|
'Длительность: ' + sec + ' с\n' +
|
||||||
|
'Средняя скорость (клиент): ' + avg + ' МБ/с\n' +
|
||||||
|
'HTTP статус: ' + httpStatus + '\n\n';
|
||||||
|
|
||||||
|
out += '-- СЕРВЕР (что реально дошло) --\n' +
|
||||||
|
'Имя файла: ' + f.name + '\n' +
|
||||||
|
'MIME: ' + f.mime + '\n' +
|
||||||
|
'Принято байт: ' + f.size_bytes + ' (' + f.size_mb + ' МБ)\n' +
|
||||||
|
'Чтение в память: ' + s.read_ms + ' мс\n' +
|
||||||
|
'Обработка всего: ' + s.total_ms + ' мс\n' +
|
||||||
|
'Скорость (сервер): ' + s.speed_mb_s + ' МБ/с\n' +
|
||||||
|
'MD5 принятых байт: ' + data.md5 + '\n\n';
|
||||||
|
|
||||||
|
out += '-- ЗАПРОС --\n' +
|
||||||
|
'Время сервера: ' + m.server_start + '\n' +
|
||||||
|
'Remote addr: ' + m.remote_addr + '\n' +
|
||||||
|
'Content-Length (заголовок): ' + m.content_length_header + '\n' +
|
||||||
|
'Content-Type (заголовок): ' + m.content_type_header + '\n' +
|
||||||
|
'User-Agent: ' + m.user_agent;
|
||||||
|
|
||||||
|
result.textContent = out;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user