Скачивание образов по ссылке и из каталога готовых систем было добавлено

- web/features/downloads.py: фоновая операция типа «download» — urllib по 1 МиБ,
  таймаут 30 с, User-Agent Virtuality/<версия>, запись в .<имя>.part и переименование
  в конце; в сообщении скорость и остаток («245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин»);
  отмена между кусками (POST /api/operations/{id}/cancel и /downloads/{id}/cancel);
  проверка схемы, имени файла, занятости имени и свободного места (Content-Length + 512 МиБ);
  для каталога — проверка контрольной суммы (SHA256SUMS/SHA512SUMS/одиночный хеш),
  при несовпадении файл удаляется; после ISO — virsh pool-refresh virtuality-iso,
  после .img.xz — распаковка и запуск конвертации в qcow2 как при загрузке с компьютера.
- /iso и /disk-images: карточка «Скачать по ссылке», на /disk-images — «Каталог готовых
  систем» для процессора сервера с пометками «Рекомендуем», «Загружен», «Скачивается»;
  идущие скачивания вверху страниц с живым прогрессом (опрос раз в 2 с) и «Отменить».
- Тесты: парсер контрольных сумм, проверка ссылок и имён, загрузка с локального
  http-сервера, редирект, отмена, несовпадение суммы, нехватка места, .img.xz, маршруты.
- В ветку скопированы web/core.py, web/catalog.py, presenters.format_bytes и tests/fixtures
  из общей рабочей копии (без изменений), conftest подменяет run_cmd и каталоги и в core.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 14:49:43 +00:00
родитель 873b32c517
Коммит 4fd04be588
27 изменённых файлов: 1662 добавлений и 7 удалений
+71
Просмотреть файл
@@ -0,0 +1,71 @@
{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk'). -#}
{% from "_ui.html" import icon %}
{% set dl = downloads_panel(kind) %}
{% if dl.active %}
<article class="card downloads" data-downloads>
<div class="card-header">
<div><h2>Скачиваем на сервер</h2><p>Страницу можно закрыть — загрузка продолжится. Когда файл будет готов, он появится в списке.</p></div>
<a class="btn btn-sm btn-ghost hide-sm" href="/operations">{{ icon('list-checks', 'icon-sm') }}Все задачи</a>
</div>
<div class="list">
{% for op in dl.active %}
<div class="list-item download-item" data-download-op="{{ op.id }}" data-status="{{ op.status }}">
<span class="list-icon info">{{ icon('download') }}</span>
<div class="grow">
<div class="row-between">
<div class="title truncate" title="{{ op.url }}">{{ op.filename }}</div>
<strong class="download-pct" data-dl="pct">{{ op.progress|default(0) }}%</strong>
</div>
<div class="progress mt-1{% if op.indeterminate %} indeterminate{% endif %}" data-dl="progress"><span data-dl="bar" style="width:{{ op.progress|default(0) }}%"></span></div>
<div class="meta mt-1" data-dl="message">{{ op.message }}</div>
</div>
<a class="btn btn-sm btn-ghost hide-sm" href="/operations/{{ op.id }}">Подробнее</a>
<form method="post" action="/downloads/{{ op.id }}/cancel" class="inline-form" data-dl="cancel" data-confirm="Скачивание {{ op.filename }} остановится, а недокачанный файл будет удалён." data-confirm-title="Отменить скачивание?" data-confirm-button="Остановить" data-toast="Скачивание отменено" data-toast-tone="warning">
<button class="btn btn-sm btn-danger-soft" {% if op.cancel_requested %}disabled{% endif %}>{{ icon('x', 'icon-sm') }}Отменить</button>
</form>
</div>
{% endfor %}
</div>
</article>
<script>
(function () {
var items = document.querySelectorAll('[data-download-op]');
if (!items.length) return;
items.forEach(function (item) {
var id = item.dataset.downloadOp;
var bar = item.querySelector('[data-dl="bar"]');
var pct = item.querySelector('[data-dl="pct"]');
var message = item.querySelector('[data-dl="message"]');
var progress = item.querySelector('[data-dl="progress"]');
var cancel = item.querySelector('[data-dl="cancel"]');
var timer = setInterval(async function () {
var op;
try {
var response = await fetch('/api/operations/' + encodeURIComponent(id), { cache: 'no-store', headers: { Accept: 'application/json' } });
if (!response.ok) return;
op = (await response.json()).operation || {};
} catch (_) { return; }
var value = Math.max(0, Math.min(100, Number(op.progress) || 0));
bar.style.width = value + '%';
pct.textContent = value + '%';
message.textContent = op.message || '';
if (op.total_bytes) progress.classList.remove('indeterminate');
if (op.cancel_requested) cancel.querySelector('button').disabled = true;
if (op.status !== 'success' && op.status !== 'error') return;
clearInterval(timer);
progress.classList.remove('indeterminate');
progress.classList.add(op.status === 'success' ? 'success' : 'danger');
cancel.hidden = true;
item.dataset.status = op.status;
if (op.status === 'success') {
try { sessionStorage.setItem('virtualityToast', JSON.stringify({ message: op.message || 'Файл скачан', tone: 'success' })); } catch (_) {}
setTimeout(function () { location.replace(location.pathname); }, 900);
} else {
message.classList.add('download-error');
if (window.vToast) window.vToast(op.message || 'Скачивание не удалось', 'danger');
}
}, 2000);
});
})();
</script>
{% endif %}
+36
Просмотреть файл
@@ -0,0 +1,36 @@
{#- Карточка «Каталог готовых систем» для страницы /disk-images. -#}
{% from "_ui.html" import icon, badge %}
{% set dl = downloads_panel('disk') %}
<article class="card">
<div class="card-header">
<div><h2>Каталог готовых систем</h2><p>Официальные образы для вашего процессора. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.</p></div>
</div>
{% if dl.catalog %}
<div class="list">
{% for item in dl.catalog %}
<div class="list-item catalog-item">
<span class="list-icon {{ 'success' if item.status == 'ready' else 'info' if item.status == 'downloading' else 'brand' }}">{{ icon('circle-check' if item.status == 'ready' else 'download' if item.status == 'downloading' else 'box') }}</span>
<div class="grow">
<div class="row" style="gap:8px">
<span class="title">{{ item.title }}</span>
{% if item.recommended %}{{ badge('Рекомендуем', 'brand', dot=False) }}{% endif %}
{% if item.status == 'ready' %}{{ badge('Загружен', 'success') }}{% elif item.status == 'downloading' %}{{ badge('Скачивается', 'info') }}{% endif %}
</div>
<div class="meta">{{ item.hint }}{% if item.login %} · вход: <code>{{ item.login }}</code>{% endif %}</div>
</div>
{% if item.status == 'ready' %}
<a class="btn btn-sm btn-primary" href="/vm/create?image={{ item.ready_file|urlencode }}">{{ icon('plus', 'icon-sm') }}Создать машину</a>
{% elif item.status == 'downloading' %}
<a class="btn btn-sm btn-ghost" href="/operations/{{ item.operation_id }}">Прогресс{{ icon('chevron-right', 'icon-sm') }}</a>
{% else %}
<form method="post" action="/downloads/catalog/{{ item.id }}" class="inline-form" data-busy="Запускаем…">
<button type="submit" class="btn btn-sm">{{ icon('download', 'icon-sm') }}Скачать</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="card-body"><p class="muted">Для процессора этого сервера готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.</p></div>
{% endif %}
</article>
+4
Просмотреть файл
@@ -0,0 +1,4 @@
{#- Сообщения после запуска/отмены скачивания (download_message / download_error в query string). -#}
{% from "_ui.html" import alert %}
{% if request.query_params.get('download_message') %}{{ alert('success', request.query_params.get('download_message')) }}{% endif %}
{% if request.query_params.get('download_error') %}{{ alert('danger', request.query_params.get('download_error')) }}{% endif %}
+17
Просмотреть файл
@@ -0,0 +1,17 @@
{#- Карточка «Скачать по ссылке». Ожидает переменную kind ('iso' | 'disk'). -#}
{% from "_ui.html" import icon %}
<article class="card">
<div class="card-header"><div><h2>Скачать по ссылке</h2><p>Сервер скачает файл сам — не нужно гонять его через компьютер</p></div></div>
<div class="card-body">
<form method="post" action="/downloads/url" class="stack-sm" data-busy="Проверяем ссылку…">
<input type="hidden" name="kind" value="{{ kind }}">
<div class="field">
<label for="download-url-{{ kind }}">Адрес файла</label>
<input class="input" id="download-url-{{ kind }}" type="url" name="url" inputmode="url" spellcheck="false" autocomplete="off" required
placeholder="{{ 'https://…/ubuntu-26.04-server.iso' if kind == 'iso' else 'https://…/debian-13-cloud.qcow2' }}">
<span class="hint">{{ 'Ссылка должна вести на файл .iso — например, со страницы загрузки Ubuntu или Debian.' if kind == 'iso' else 'Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz — распакуем и подготовим сами.' }}</span>
</div>
<div class="row"><button type="submit" class="btn btn-primary">{{ icon('download') }}Скачать на сервер</button></div>
</form>
</div>
</article>
+8 -1
Просмотреть файл
@@ -3,9 +3,14 @@
{% set page_title = 'Готовые системы' %}
{% block page_header %}{% include "_images_header.html" %}{% endblock %}
{% block alerts %}{{ super() }}{% include "_downloads_flash.html" %}{% endblock %}
{% block content %}
{% set kind = 'disk' %}
{% include "_downloads_active.html" %}
<section class="grid grid-main">
<div class="stack">
{% include "_downloads_catalog.html" %}
<article class="card">
<div class="card-header"><div><h2>Готовые системы</h2><p>Образы уже установленных систем — машина запускается сразу, без установки</p></div></div>
{% if images %}
@@ -22,9 +27,10 @@
{% endfor %}
</div>
{% else %}
{{ empty('hard-drive', 'Готовых образов пока нет', 'Подойдут облачные образы Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }}
{{ empty('hard-drive', 'Готовых образов пока нет', 'Выберите систему в каталоге выше — или загрузите свой образ: облачные Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }}
{% endif %}
</article>
</div>
<aside class="stack">
<article class="card">
@@ -33,6 +39,7 @@
{{ uploader('/disk-images/upload', 'image_file', '.qcow2,.img,.raw,.img.xz,.zip,.tar.gz,.tgz', 'Перетащите файл образа сюда', '.qcow2, .img, .raw, а также архивы .img.xz, .zip, .tar.gz', '/disk-images', 'Загрузить') }}
</div>
</article>
{% include "_downloads_url.html" %}
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('sparkles') }}<h3>Всё делается автоматически</h3></div>
+4
Просмотреть файл
@@ -3,8 +3,11 @@
{% set page_title = 'Образы ОС' %}
{% block page_header %}{% include "_images_header.html" %}{% endblock %}
{% block alerts %}{{ super() }}{% include "_downloads_flash.html" %}{% endblock %}
{% block content %}
{% set kind = 'iso' %}
{% include "_downloads_active.html" %}
<section class="grid grid-main">
<article class="card">
<div class="card-header">
@@ -36,6 +39,7 @@
{{ uploader('/iso/upload', 'iso_file', '.iso', 'Перетащите файл .iso сюда', 'или нажмите, чтобы выбрать на компьютере', '/iso', 'Загрузить') }}
</div>
</article>
{% include "_downloads_url.html" %}
<article class="card">
<div class="card-header"><h2>Где взять образ</h2></div>
<div class="list">