Files
Claude c2dc32947f Скачивание образов было доработано по итогам ревью: зависшие операции, гонка запусков, отмена при проверке и распаковке
- Проверка «уже скачивается» идёт по всем операциям каталога (glob), а не по последним 50;
  скачивания без живого потока закрываются при старте (reap_stale_downloads), их .part удаляются,
  а отмена такой операции завершает её сразу, не дожидаясь потока.
- Два запуска одного файла больше не пишут в один .part: проверки и создание операции под замком,
  файл создаётся монопольно (O_EXCL), вторая копия завершается ошибкой, не трогая чужой файл;
  осиротевший .part без владельца убирается перед новым запуском.
- Отмена во время проверки контрольной суммы и распаковки .img.xz останавливает работу:
  операция завершается как отменённая, недокачанный файл, архив и недораспакованный образ удаляются.
- Битый .img.xz после неудачной распаковки удаляется с понятным сообщением, повторное скачивание разрешено;
  проверка «файл уже есть» учитывает распакованный .img и qcow2 после конвертации.
- Ссылки с кириллицей (домен IDNA, путь и параметры в процентах) открываются, некорректный порт
  и непонятные символы отклоняются сразу; ошибки сети описаны словами, исходный текст — в журнале.
- Прогресс раз в секунду перечитывает JSON операции, чтобы не затирать cancel_requested и замечать
  отмену из другого процесса; операции читаются один раз на страницу; отмена в карточке — не ошибка.
- Формулировки карточек и сообщения о недоступной архитектуре упрощены; добавлены тесты на каждое поведение.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:31:12 +00:00

80 строки
4.9 KiB
HTML

{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk');
страница может заранее положить в dl результат downloads_panel(kind), чтобы не читать операции дважды. -#}
{% from "_ui.html" import icon %}
{% set dl = dl if dl is defined else 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>
{#- Без data-toast: сервер лишь просит поток остановиться, а итог («Скачивание отменено») покажет опрос ниже. -#}
<form method="post" action="/downloads/{{ op.id }}/cancel" class="inline-form" data-dl="cancel" data-confirm="Скачивание {{ op.filename }} остановится, а недокачанный файл будет удалён." data-confirm-title="Отменить скачивание?" data-confirm-button="Остановить">
<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');
cancel.hidden = true;
if (op.status === 'success') {
progress.classList.add('success');
item.dataset.status = op.status;
try { sessionStorage.setItem('virtualityToast', JSON.stringify({ message: op.message || 'Файл скачан', tone: 'success' })); } catch (_) {}
setTimeout(function () { location.replace(location.pathname); }, 900);
} else if (op.cancelled) {
// Отмена по просьбе пользователя — не ошибка: предупреждение, без красной строки и красного процента.
item.dataset.status = 'cancelled';
if (window.vToast) window.vToast(op.message || 'Скачивание отменено', 'warning');
} else {
progress.classList.add('danger');
item.dataset.status = op.status;
message.classList.add('download-error');
if (window.vToast) window.vToast(op.message || 'Скачивание не удалось', 'danger');
}
}, 2000);
});
})();
</script>
{% endif %}