Скачивание образов по ссылке и из каталога готовых систем было добавлено
- 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
Этот коммит содержится в:
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Ссылка в новой задаче
Block a user