Мастер первичной настройки и nodectl: добавлены пошаговый /setup, управление узлом через virtuality-ctl и тесты

- web/nodectl.py: факты об узле (сеть, диски, часовые пояса, KVM) и действия через virtuality-ctl
- web/features/setup.py + шаблоны setup*: мастер из 8 шагов (установка, часовой пояс, пароль, HTTPS, сеть, хранилище, обновления, итог)
- безопасное переключение на мост br0 с авто-откатом и подтверждением из браузера
- virtuality-ctl storage-use: перенос данных на отдельный диск
- панель перенаправляет на мастер до его завершения; демо-режим VIRTUALITY_DEV_SETUP
- tests/test_setup.py: 8 тестов на маршруты мастера

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 14:36:32 +00:00
родитель 7bf7a0f6b7
Коммит 8a4474a305
17 изменённых файлов: 1217 добавлений и 0 удалений
+43
Просмотреть файл
@@ -475,3 +475,46 @@
flashToast();
});
})();
/* Setup wizard: live progress of the first-boot installation. */
(() => {
'use strict';
document.addEventListener('DOMContentLoaded', () => {
const root = document.querySelector('[data-setup-install]');
if (!root) return;
const list = root.querySelector('[data-setup-steps]');
const log = root.querySelector('[data-setup-log]');
const status = document.querySelector('[data-setup-status]');
const next = document.querySelector('[data-setup-next]');
const escapeHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
const icon = (name) => `<svg class="icon" aria-hidden="true"><use href="/static/icons.svg#i-${name}"></use></svg>`;
let announced = root.dataset.installed === '1';
async function poll() {
try {
const response = await fetch('/api/setup/state', { cache: 'no-store' });
if (!response.ok) return;
const { state } = await response.json();
if (state.steps && state.steps.length) {
list.innerHTML = state.steps.map((item) => {
const tone = item.status === 'done' ? 'success' : item.status === 'error' ? 'danger' : item.status === 'running' ? 'warning' : '';
const mark = item.status === 'done' ? icon('circle-check') : item.status === 'error' ? icon('circle-x') : item.status === 'running' ? '<span class="spinner" style="width:18px;height:18px;margin-top:2px"></span>' : icon('circle-dot');
const detail = item.status === 'running' && state.message ? `<span>${escapeHtml(state.message)}</span>` : '';
return `<div class="check-item ${tone}">${mark}<div><strong>${escapeHtml(item.title)}</strong>${detail}</div></div>`;
}).join('');
}
if (log && state.log_tail) { const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40; log.textContent = state.log_tail; if (atBottom) log.scrollTop = log.scrollHeight; }
if (state.installed) {
next.classList.remove('disabled');
status.textContent = 'Всё установлено';
if (!announced) { announced = true; window.vToast && window.vToast('Компоненты установлены'); setTimeout(() => { location.href = root.dataset.next; }, 2000); }
} else if (state.stage === 'waiting-network') {
status.textContent = 'Ждём подключение к интернету…';
} else if (state.stage === 'error') {
status.textContent = 'Ошибка установки — повторим автоматически';
}
} catch (_) {}
}
poll();
setInterval(poll, 3000);
});
})();