From 8a4474a3057549390fe3f2ae11a219fda9b448a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:36:32 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=B0=D1=81=D1=82=D0=B5=D1=80=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B8=D1=87=D0=BD=D0=BE=D0=B9=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8=20nodectl:?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=88=D0=B0=D0=B3=D0=BE=D0=B2=D1=8B=D0=B9=20/setup?= =?UTF-8?q?,=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=83=D0=B7=D0=BB=D0=BE=D0=BC=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20virtuality-ctl=20=D0=B8=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- .claude/worktrees/wf_18d9ca1d-93c-1 | 1 + .claude/worktrees/wf_18d9ca1d-93c-2 | 1 + .claude/worktrees/wf_18d9ca1d-93c-3 | 1 + .claude/worktrees/wf_18d9ca1d-93c-4 | 1 + scripts/virtuality-ctl | 37 ++++ tests/dev_server.py | 19 ++ tests/test_setup.py | 101 +++++++++ web/app.py | 6 + web/features/__init__.py | 0 web/features/setup.py | 287 ++++++++++++++++++++++++ web/nodectl.py | 313 +++++++++++++++++++++++++++ web/static/app.css | 28 +++ web/static/panel.js | 43 ++++ web/templates/setup.html | 241 +++++++++++++++++++++ web/templates/setup_applying.html | 30 +++ web/templates/setup_base.html | 60 +++++ web/templates/setup_bridge_wait.html | 48 ++++ 17 files changed, 1217 insertions(+) create mode 160000 .claude/worktrees/wf_18d9ca1d-93c-1 create mode 160000 .claude/worktrees/wf_18d9ca1d-93c-2 create mode 160000 .claude/worktrees/wf_18d9ca1d-93c-3 create mode 160000 .claude/worktrees/wf_18d9ca1d-93c-4 create mode 100644 tests/test_setup.py create mode 100644 web/features/__init__.py create mode 100644 web/features/setup.py create mode 100644 web/nodectl.py create mode 100644 web/templates/setup.html create mode 100644 web/templates/setup_applying.html create mode 100644 web/templates/setup_base.html create mode 100644 web/templates/setup_bridge_wait.html diff --git a/.claude/worktrees/wf_18d9ca1d-93c-1 b/.claude/worktrees/wf_18d9ca1d-93c-1 new file mode 160000 index 0000000..4198c18 --- /dev/null +++ b/.claude/worktrees/wf_18d9ca1d-93c-1 @@ -0,0 +1 @@ +Subproject commit 4198c18ff4aabeac0bb370fce136469f3f8fe6b8 diff --git a/.claude/worktrees/wf_18d9ca1d-93c-2 b/.claude/worktrees/wf_18d9ca1d-93c-2 new file mode 160000 index 0000000..2450278 --- /dev/null +++ b/.claude/worktrees/wf_18d9ca1d-93c-2 @@ -0,0 +1 @@ +Subproject commit 2450278d936bf3b6cd4a045d7eb69635773bf862 diff --git a/.claude/worktrees/wf_18d9ca1d-93c-3 b/.claude/worktrees/wf_18d9ca1d-93c-3 new file mode 160000 index 0000000..873b32c --- /dev/null +++ b/.claude/worktrees/wf_18d9ca1d-93c-3 @@ -0,0 +1 @@ +Subproject commit 873b32c517d5c3af6e1c1b94147ee5b1840fd55a diff --git a/.claude/worktrees/wf_18d9ca1d-93c-4 b/.claude/worktrees/wf_18d9ca1d-93c-4 new file mode 160000 index 0000000..7bf7a0f --- /dev/null +++ b/.claude/worktrees/wf_18d9ca1d-93c-4 @@ -0,0 +1 @@ +Subproject commit 7bf7a0f6b7bc72a3bcec589fc036cbffeae9ef57 diff --git a/scripts/virtuality-ctl b/scripts/virtuality-ctl index a76c185..cfeac79 100755 --- a/scripts/virtuality-ctl +++ b/scripts/virtuality-ctl @@ -35,6 +35,7 @@ Usage: virtuality-ctl [options] bridge [dhcp|static] Подключить машины к локальной сети через мост br0 с автооткатом bridge-confirm Подтвердить новую сеть (отменяет автооткат) bridge-revert Вернуть прежнюю сеть + storage-use /dev/sdX Отдать пустой диск под хранилище машин (${DATA_DIR}) support-bundle [FILE] Собрать отчёт для поддержки uninstall [--purge] [--yes] Удалить Virtuality (по умолчанию машины и образы остаются) EOF @@ -557,6 +558,41 @@ cmd_bridge_revert() { echo "Прежняя сеть восстановлена" } +# ---------------------------------------------------------------- storage +cmd_storage_use() { + need_root storage-use + local dev="${1:-}" + [[ "$dev" =~ ^/dev/[a-zA-Z0-9]+$ ]] || die "usage: virtuality-ctl storage-use /dev/sdX" + [[ -b "$dev" ]] || die "$dev не является блочным устройством" + local children fstype mounted + children="$(lsblk -n -o NAME "$dev" | tail -n +2 | wc -l)" + fstype="$(lsblk -n -d -o FSTYPE "$dev")" + mounted="$(lsblk -n -o MOUNTPOINT "$dev" | tr -d ' \n')" + [[ "$children" -eq 0 && -z "$fstype" && -z "$mounted" ]] || die "на $dev уже есть разделы или данные — используйте только пустой диск" + findmnt -n --target "$DATA_DIR" -o SOURCE | grep -q "^${dev}" && die "$dev уже используется" + log "storage-use ${dev}: formatting" + wipefs -a "$dev" >/dev/null + mkfs.ext4 -F -q -L virtuality "$dev" >/dev/null || die "не удалось создать файловую систему" + local uuid tmp + uuid="$(blkid -s UUID -o value "$dev")" + [[ -n "$uuid" ]] || die "не удалось прочитать UUID" + tmp="$(mktemp -d)" + mount "$dev" "$tmp" || die "не удалось смонтировать диск" + mkdir -p "$DATA_DIR" + rsync -a "$DATA_DIR"/ "$tmp"/ || { umount "$tmp"; die "не удалось перенести данные"; } + umount "$tmp"; rmdir "$tmp" + sed -i "\#[[:space:]]${DATA_DIR}[[:space:]]#d" /etc/fstab + printf 'UUID=%s %s ext4 defaults,nofail 0 2\n' "$uuid" "$DATA_DIR" >> /etc/fstab + systemctl daemon-reload + mount "$DATA_DIR" || die "не удалось смонтировать ${DATA_DIR}" + chmod 755 "$DATA_DIR" + mkdir -p "$DATA_DIR"/{iso,images,disk-images,backups,tmp,network,config} + chmod 1777 "$DATA_DIR/tmp" + command -v virsh >/dev/null 2>&1 && { virsh pool-refresh virtuality-images >/dev/null 2>&1 || true; virsh pool-refresh virtuality-iso >/dev/null 2>&1 || true; } + log "storage-use ${dev}: mounted at ${DATA_DIR}" + echo "Диск ${dev} подключён как ${DATA_DIR}" +} + # ---------------------------------------------------------------- support cmd_support_bundle() { need_root support-bundle @@ -658,6 +694,7 @@ case "${1:-}" in bridge) shift; cmd_bridge "$@" ;; bridge-confirm) cmd_bridge_confirm ;; bridge-revert) cmd_bridge_revert ;; + storage-use) shift; cmd_storage_use "$@" ;; support-bundle) shift; cmd_support_bundle "$@" ;; uninstall) shift; cmd_uninstall "$@" ;; -h|--help|help|"") usage ;; diff --git a/tests/dev_server.py b/tests/dev_server.py index 9df2e62..9dd38f2 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -4,6 +4,7 @@ python3 tests/dev_server.py [port] # login: tester / any password """ import json +import os import sys import tempfile import uuid @@ -35,6 +36,24 @@ def seed(root: Path) -> None: for module in (app, network_core, host_profile): module.run_cmd = conftest.fake_run_cmd app.auth.verify_password = lambda user, password: True + import nodectl + + nodectl.CONFIG_DIR = dirs["config"] + nodectl.SETUP_STATE = dirs["config"] / "setup.json" + nodectl.SETUP_DONE = dirs["config"] / "setup_done" + nodectl.WIZARD_FILE = dirs["config"] / "wizard.json" + stage = os.environ.get("VIRTUALITY_DEV_SETUP", "") + if stage: + # VIRTUALITY_DEV_SETUP=installing|done shows the setup wizard in the demo. + steps = [("panel", "Панель управления", "done"), ("network-wait", "Подключение к интернету", "done"), ("virtualization", "KVM, QEMU и libvirt", "running" if stage == "installing" else "done"), ("tools", "Инструменты диагностики", "pending" if stage == "installing" else "done"), ("finish", "Завершение", "pending" if stage == "installing" else "done")] + nodectl.SETUP_STATE.write_text(json.dumps({"stage": "installing" if stage == "installing" else "done", "message": "Устанавливаем qemu-system-x86 (14 из 61)…", "steps": [{"id": i, "title": t, "status": s} for i, t, s in steps]})) + nodectl.FIRSTBOOT_LOG = dirs["config"] / "firstboot.log" + nodectl.FIRSTBOOT_LOG.write_text("[2026-09-25 14:02:11] [virtuality-firstboot] step 1/4: web panel\n[2026-09-25 14:02:40] [virtuality-firstboot] step 2/4: virtualization node (KVM, libvirt)\nGet:14 http://archive.ubuntu.com/ubuntu resolute/main amd64 qemu-system-x86 amd64 1:10.2.1+ds-1ubuntu3 [9 812 kB]\n") + if not nodectl.ctl_available(): + nodectl.spare_disks = lambda: [{"path": "/dev/sdb", "name": "sdb", "size": 2 * 1024 ** 4, "model": "WD Red 2TB", "tran": "sata"}] + nodectl.network_facts = lambda: {"interface": "enp3s0", "gateway": "192.168.1.1", "address": "192.168.1.10", "prefix": 24, "mac": "aa:bb:cc:dd:ee:ff", "wireless": False, "private": True, "is_vps": False, "virt": "none", "bridge_present": False, "on_bridge": False, "recommended": "bridge", "revert_armed": False, "bridge_possible": True} + nodectl.hardware_summary = lambda: {"hostname": "home-server", "cpu_model": "Intel Core i5-12400", "cpu_count": 6, "mem_total": 32 * 1024 ** 3, "disk_total": 480 * 1024 ** 3, "disk_free": 401 * 1024 ** 3, "kvm": True} + nodectl.set_timezone = lambda tz: (True, tz) for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)): with (dirs["iso"] / name).open("wb") as handle: diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..1ff7e03 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,101 @@ +import json + +import app +import nodectl +from features import setup + + +def prepare(data_dirs, monkeypatch, stage="done"): + config = data_dirs["config"] + monkeypatch.setattr(nodectl, "CONFIG_DIR", config) + monkeypatch.setattr(nodectl, "SETUP_STATE", config / "setup.json") + monkeypatch.setattr(nodectl, "SETUP_DONE", config / "setup_done") + monkeypatch.setattr(nodectl, "WIZARD_FILE", config / "wizard.json") + monkeypatch.setattr(nodectl, "FIRSTBOOT_LOG", config / "firstboot.log") + monkeypatch.setattr(nodectl, "ctl_available", lambda: False) + (config / "setup.json").write_text(json.dumps({"stage": stage, "message": "Устанавливаем…", "steps": [{"id": "panel", "title": "Панель", "status": "done"}, {"id": "virt", "title": "KVM", "status": "running" if stage != "done" else "done"}]})) + monkeypatch.setattr(nodectl, "network_facts", lambda: {"interface": "enp3s0", "gateway": "192.168.1.1", "address": "192.168.1.10", "prefix": 24, "mac": "aa:bb:cc:dd:ee:ff", "wireless": False, "private": True, "is_vps": False, "virt": "none", "bridge_present": False, "on_bridge": False, "recommended": "bridge", "revert_armed": False, "bridge_possible": True}) + monkeypatch.setattr(nodectl, "spare_disks", lambda: []) + monkeypatch.setattr(nodectl, "timezones", lambda: {"current": "UTC", "popular": ["Europe/Moscow", "UTC"], "all": ["Europe/Moscow", "UTC"]}) + + +def test_dashboard_redirects_to_wizard_until_done(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + response = logged_in.get("/", follow_redirects=False) + assert response.status_code == 303 and response.headers["location"] == "/setup" + nodectl.mark_wizard_done() + assert logged_in.get("/", follow_redirects=False).status_code == 200 + + +def test_dashboard_without_installer_state_has_no_wizard(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + nodectl.SETUP_STATE.unlink() + assert logged_in.get("/", follow_redirects=False).status_code == 200 + + +def test_install_step_shown_while_installing(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch, stage="installing") + assert logged_in.get("/setup", follow_redirects=False).headers["location"] == "/setup/install" + html = logged_in.get("/setup/install").text + assert "Настраиваем сервер" in html and "Устанавливаем…" in html + # other steps are locked until the installation finishes + assert logged_in.get("/setup/welcome", follow_redirects=False).headers["location"] == "/setup/install" + state = logged_in.get("/api/setup/state").json() + assert state["ok"] and state["state"]["installed"] is False + + +def test_wizard_steps_render_and_store_choices(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + monkeypatch.setattr(nodectl, "set_timezone", lambda tz: (True, tz)) + for step in setup.STEP_IDS[1:]: + assert logged_in.get(f"/setup/{step}").status_code == 200, step + assert logged_in.post("/setup/welcome", data={"timezone": "Europe/Moscow"}, follow_redirects=False).headers["location"] == "/setup/account" + assert logged_in.post("/setup/account", data={"password": "", "password2": ""}, follow_redirects=False).headers["location"] == "/setup/access" + assert logged_in.post("/setup/account", data={"password": "abcdef", "password2": "other"}).status_code == 400 + assert logged_in.post("/setup/access", data={"tls": "0", "port": "8090"}, follow_redirects=False).headers["location"] == "/setup/network" + assert logged_in.post("/setup/access", data={"tls": "1", "port": "22"}).status_code == 400 + monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {}) + assert logged_in.post("/setup/network", data={"mode": "nat"}, follow_redirects=False).headers["location"] == "/setup/storage" + assert logged_in.post("/setup/storage", data={"disk": "keep"}, follow_redirects=False).headers["location"] == "/setup/updates" + assert logged_in.post("/setup/updates", data={"auto_update": "0", "channel": "stable"}, follow_redirects=False).headers["location"] == "/setup/finish" + choices = nodectl.wizard_choices() + assert choices["timezone"] == "Europe/Moscow" and choices["port"] == "8090" and choices["tls"] == "0" and choices["network"] == "nat" and choices["auto_update"] == "0" + finish = logged_in.get("/setup/finish").text + assert "http://" in finish and "Europe/Moscow" in finish and "Вручную" in finish + + +def test_finish_marks_done_without_ctl(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + response = logged_in.post("/setup/finish", follow_redirects=False) + assert response.status_code == 303 and response.headers["location"].startswith("/?setup_message=") + assert nodectl.wizard_done() + + +def test_finish_applies_settings_through_ctl(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + calls = [] + monkeypatch.setattr(nodectl, "ctl_available", lambda: True) + monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""}) + nodectl.save_wizard_choices(tls="1", port="8088", auto_update="1", channel="stable") + response = logged_in.post("/setup/finish", follow_redirects=False) + assert response.status_code == 200 and "https://panel.test:8443" in response.text + assert calls and calls[0][0] == "set" and "VIRTUALITY_TLS=1" in calls[0] + + +def test_bridge_mode_starts_network_change(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + calls = [] + monkeypatch.setattr(nodectl, "enable_bridge", lambda iface, mode="dhcp": calls.append((iface, mode)) or (True, "ok")) + response = logged_in.post("/setup/network", data={"mode": "bridge"}) + assert response.status_code == 200 and "Перенастраиваем сеть" in response.text + assert calls == [("enp3s0", "dhcp")] + monkeypatch.setattr(nodectl, "confirm_bridge", lambda: (True, "Сеть подтверждена")) + assert logged_in.post("/setup/network/confirm").json()["ok"] is True + assert nodectl.wizard_choices()["bridge_pending"] is False + + +def test_restart_wizard_from_settings(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + nodectl.mark_wizard_done() + assert logged_in.post("/setup/restart", follow_redirects=False).headers["location"] == "/setup/welcome" + assert not nodectl.wizard_done() diff --git a/web/app.py b/web/app.py index c911878..28977a8 100644 --- a/web/app.py +++ b/web/app.py @@ -28,6 +28,7 @@ import auth import host_profile import network_core import presenters +from features import setup import update_core from network_core import NetworkError @@ -1341,6 +1342,8 @@ def dashboard(request: Request): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect + if setup.wizard_pending(): + return RedirectResponse(url="/setup", status_code=303) services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")} service_rows = [{"key": key, "name": presenters.SERVICE_LABELS.get(key, key), "state": state, **presenters.service_state(state)} for key, state in services.items()] return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()}) @@ -2016,3 +2019,6 @@ def api_health(request: Request): if not get_current_user(request): return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()} + + +app.include_router(setup.router) diff --git a/web/features/__init__.py b/web/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/features/setup.py b/web/features/setup.py new file mode 100644 index 0000000..aa04be1 --- /dev/null +++ b/web/features/setup.py @@ -0,0 +1,287 @@ +"""First-run setup wizard (/setup) and the "applying settings" page.""" +import re +from typing import Any + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse, RedirectResponse + +import network_core +import nodectl +import presenters +from core import AUTH_USER, cmd_error, get_current_user, redirect_with_message, render, require_auth, run_cmd + +router = APIRouter() + +STEPS = [ + {"id": "install", "title": "Установка", "hint": "Компоненты сервера"}, + {"id": "welcome", "title": "Сервер", "hint": "Проверка и время"}, + {"id": "account", "title": "Администратор", "hint": "Вход в панель"}, + {"id": "access", "title": "Доступ", "hint": "Адрес и HTTPS"}, + {"id": "network", "title": "Сеть машин", "hint": "NAT или домашняя сеть"}, + {"id": "storage", "title": "Хранилище", "hint": "Где живут машины"}, + {"id": "updates", "title": "Обновления", "hint": "Как обновляться"}, + {"id": "finish", "title": "Готово", "hint": "Проверка и запуск"}, +] +STEP_IDS = [step["id"] for step in STEPS] + + +def wizard_pending() -> bool: + """The wizard is offered until finished on nodes set up by the installer or the ISO.""" + return not nodectl.wizard_done() and nodectl.SETUP_STATE.exists() + + +def step_index(step_id: str) -> int: + return STEP_IDS.index(step_id) if step_id in STEP_IDS else 0 + + +def next_step(step_id: str) -> str: + index = step_index(step_id) + return STEP_IDS[min(index + 1, len(STEP_IDS) - 1)] + + +def render_step(request: Request, step_id: str, error: str | None = None, message: str | None = None, status_code: int = 200): + choices = nodectl.wizard_choices() + settings = nodectl.node_settings() + state = nodectl.setup_state() + host = request.url.hostname or "127.0.0.1" + context: dict[str, Any] = { + "steps": STEPS, "step": step_id, "step_index": step_index(step_id), "choices": choices, "settings": settings, + "state": state, "error": error, "message": message, "host": host, "auth_user": AUTH_USER, + "page_title": "Мастер настройки", + } + if step_id == "welcome": + context["hardware"] = nodectl.hardware_summary() + context["timezones"] = nodectl.timezones() + context["profile_ready"] = context["hardware"]["kvm"] + elif step_id == "access": + context["tls"] = choices.get("tls", settings.get("VIRTUALITY_TLS", "0") == "1" or not settings.get("VIRTUALITY_TLS_SET")) + context["port"] = choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088")) + elif step_id == "network": + context["net"] = nodectl.network_facts() + context["nat_ready"] = network_core.libvirt_network_info()["exists"] + elif step_id == "storage": + context["storage"] = nodectl.storage_facts() + elif step_id == "finish": + context["net"] = nodectl.network_facts() + context["hardware"] = nodectl.hardware_summary() + context["storage"] = nodectl.storage_facts() + context["timezone"] = choices.get("timezone") or nodectl.timezones()["current"] + tls = str(choices.get("tls", "1")) == "1" + port = str(choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088"))) + context["final_url"] = f"https://{host}:{settings.get('VIRTUALITY_TLS_PORT', '8443')}" if tls else f"http://{host}:{port}" + context["restart_needed"] = (tls != (settings.get("VIRTUALITY_TLS") == "1")) or port != settings.get("VIRTUALITY_WEB_PORT", "8088") or str(choices.get("auto_update", settings.get("VIRTUALITY_AUTO_UPDATE", "1"))) != settings.get("VIRTUALITY_AUTO_UPDATE", "1") or str(choices.get("channel", settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable"))) != settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable") + return render(request, "setup.html", context, status_code=status_code) + + +@router.get("/setup") +def setup_index(request: Request): + auth = require_auth(request) + if auth: + return auth + choices = nodectl.wizard_choices() + state = nodectl.setup_state() + if not state["installed"]: + return RedirectResponse(url="/setup/install", status_code=303) + return RedirectResponse(url=f"/setup/{choices.get('step', 'welcome')}", status_code=303) + + +@router.get("/setup/{step_id}") +def setup_step(request: Request, step_id: str, message: str = "", error: str = ""): + auth = require_auth(request) + if auth: + return auth + if step_id not in STEP_IDS: + return RedirectResponse(url="/setup", status_code=303) + if step_id != "install" and not nodectl.setup_state()["installed"]: + return RedirectResponse(url="/setup/install", status_code=303) + if step_id != "install": + nodectl.save_wizard_choices(step=step_id) + return render_step(request, step_id, error=error or None, message=message or None) + + +@router.get("/api/setup/state") +def setup_state_api(request: Request): + if not get_current_user(request): + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + state = nodectl.setup_state() + return {"ok": True, "state": state, "net": {"revert_armed": nodectl.network_facts()["revert_armed"]} if request.query_params.get("net") else None} + + +# ---------------------------------------------------------------- step handlers +@router.post("/setup/welcome") +def setup_welcome(request: Request, timezone: str = Form("")): + auth = require_auth(request) + if auth: + return auth + if timezone: + ok, detail = nodectl.set_timezone(timezone) + if not ok: + return render_step(request, "welcome", error=detail, status_code=400) + nodectl.save_wizard_choices(timezone=timezone) + return RedirectResponse(url="/setup/account", status_code=303) + + +@router.post("/setup/account") +def setup_account(request: Request, password: str = Form(""), password2: str = Form("")): + auth = require_auth(request) + if auth: + return auth + if password or password2: + if password != password2: + return render_step(request, "account", error="Пароли не совпадают", status_code=400) + ok, detail = nodectl.change_password(AUTH_USER, password) + if not ok: + return render_step(request, "account", error=detail, status_code=400) + nodectl.save_wizard_choices(password_changed=True) + return RedirectResponse(url="/setup/access", status_code=303) + + +@router.post("/setup/access") +def setup_access(request: Request, tls: str = Form("1"), port: str = Form("8088")): + auth = require_auth(request) + if auth: + return auth + if not re.fullmatch(r"\d{2,5}", port) or not 1 <= int(port) <= 65535 or int(port) in (22, 9090): + return render_step(request, "access", error="Порт должен быть числом от 1 до 65535 (кроме 22 и 9090)", status_code=400) + nodectl.save_wizard_choices(tls="1" if tls == "1" else "0", port=port) + return RedirectResponse(url="/setup/network", status_code=303) + + +@router.post("/setup/network") +def setup_network(request: Request, mode: str = Form("nat")): + auth = require_auth(request) + if auth: + return auth + if mode not in ("nat", "bridge"): + return render_step(request, "network", error="Выберите режим сети", status_code=400) + if mode == "nat": + try: + network_core.create_nat_network() + except network_core.NetworkError as exc: + return render_step(request, "network", error=f"Не удалось создать сеть машин: {exc}", status_code=500) + nodectl.save_wizard_choices(network="nat") + return RedirectResponse(url="/setup/storage", status_code=303) + facts = nodectl.network_facts() + if facts["on_bridge"]: + nodectl.save_wizard_choices(network="bridge") + return RedirectResponse(url="/setup/storage", status_code=303) + if not facts["bridge_possible"]: + return render_step(request, "network", error="На этом сервере мост недоступен (Wi-Fi или не найден проводной интерфейс). Выберите NAT.", status_code=400) + ok, detail = nodectl.enable_bridge(facts["interface"], "dhcp") + if not ok: + return render_step(request, "network", error=detail, status_code=500) + nodectl.save_wizard_choices(network="bridge", bridge_pending=True) + return render(request, "setup_bridge_wait.html", {"page_title": "Перенастройка сети", "next_url": "/setup/storage", "confirm_url": "/setup/network/confirm", "revert_url": "/setup/network/revert", "back_url": "/setup/network"}) + + +@router.post("/setup/network/confirm") +def setup_network_confirm(request: Request): + if not get_current_user(request): + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + ok, detail = nodectl.confirm_bridge() + if ok: + nodectl.save_wizard_choices(bridge_pending=False, network="bridge") + profile = None + try: + import host_profile + + profile = host_profile.detect_host_profile() + host_profile.save_host_profile(profile) + except Exception: + pass + return JSONResponse({"ok": ok, "message": detail}) + + +@router.post("/setup/network/revert") +def setup_network_revert(request: Request): + auth = require_auth(request) + if auth: + return auth + ok, detail = nodectl.revert_bridge() + nodectl.save_wizard_choices(bridge_pending=False, network="nat") + return redirect_with_message("/setup/network", "message" if ok else "error", detail) + + +@router.post("/setup/storage") +def setup_storage(request: Request, disk: str = Form("")): + auth = require_auth(request) + if auth: + return auth + if disk and disk != "keep": + ok, detail = nodectl.use_disk_for_storage(disk) + if not ok: + return render_step(request, "storage", error=detail, status_code=400) + nodectl.save_wizard_choices(storage_disk=disk) + return RedirectResponse(url="/setup/updates", status_code=303) + + +@router.post("/setup/updates") +def setup_updates(request: Request, auto_update: str = Form("1"), channel: str = Form("stable")): + auth = require_auth(request) + if auth: + return auth + if channel not in ("stable", "main"): + channel = "stable" + nodectl.save_wizard_choices(auto_update="1" if auto_update == "1" else "0", channel=channel) + return RedirectResponse(url="/setup/finish", status_code=303) + + +@router.post("/setup/finish") +def setup_finish(request: Request): + auth = require_auth(request) + if auth: + return auth + choices = nodectl.wizard_choices() + settings = nodectl.node_settings() + host = request.url.hostname or "127.0.0.1" + changes = { + "VIRTUALITY_TLS": str(choices.get("tls", "1")), + "VIRTUALITY_WEB_PORT": str(choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088"))), + "VIRTUALITY_AUTO_UPDATE": str(choices.get("auto_update", settings.get("VIRTUALITY_AUTO_UPDATE", "1"))), + "VIRTUALITY_UPDATE_CHANNEL": str(choices.get("channel", settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable"))), + } + current = {"VIRTUALITY_TLS": settings.get("VIRTUALITY_TLS", "0"), "VIRTUALITY_WEB_PORT": settings.get("VIRTUALITY_WEB_PORT", "8088"), "VIRTUALITY_AUTO_UPDATE": settings.get("VIRTUALITY_AUTO_UPDATE", "1"), "VIRTUALITY_UPDATE_CHANNEL": settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable")} + nodectl.mark_wizard_done() + nodectl.save_wizard_choices(step="finish", finished=True) + if changes != current and nodectl.ctl_available(): + ok, detail = nodectl.apply_settings(changes) + if not ok: + return render_step(request, "finish", error=detail, status_code=500) + final_url = f"https://{host}:{settings.get('VIRTUALITY_TLS_PORT', '8443')}" if changes["VIRTUALITY_TLS"] == "1" else f"http://{host}:{changes['VIRTUALITY_WEB_PORT']}" + return render(request, "setup_applying.html", {"page_title": "Применяем настройки", "final_url": final_url, "tls": changes["VIRTUALITY_TLS"] == "1"}) + return redirect_with_message("/", "setup_message", "Сервер настроен. Можно создавать машины.") + + +@router.get("/setup/skip") +def setup_skip(request: Request): + auth = require_auth(request) + if auth: + return auth + nodectl.mark_wizard_done() + return redirect_with_message("/", "setup_message", "Мастер пропущен. Его можно запустить позже в Настройках.") + + +@router.post("/setup/restart") +def setup_restart(request: Request): + """Re-run the wizard from Settings.""" + auth = require_auth(request) + if auth: + return auth + try: + nodectl.SETUP_DONE.unlink() + except OSError: + pass + nodectl.save_wizard_choices(step="welcome", finished=False) + if not nodectl.SETUP_STATE.exists(): + nodectl.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + nodectl.SETUP_STATE.write_text('{"stage": "done", "steps": []}\n') + return RedirectResponse(url="/setup/welcome", status_code=303) + + +def virtualization_hint() -> str: + result = run_cmd(["systemd-detect-virt"], timeout=5) + return cmd_error(result, "") if not result["ok"] else result["stdout"] + + +def format_bytes(value: int) -> str: + return presenters.format_bytes(value) diff --git a/web/nodectl.py b/web/nodectl.py new file mode 100644 index 0000000..5a64bd1 --- /dev/null +++ b/web/nodectl.py @@ -0,0 +1,313 @@ +"""Facts about the node (network, disks, time zone, first-boot progress) and +actions that change the node itself. Actions go through virtuality-ctl so the +panel and the command line behave identically.""" +import ipaddress +import json +import os +import re +import shutil +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any + +from core import CONFIG_DIR, STORAGE_DIR, cmd_error, run_cmd + +CTL = "/usr/local/bin/virtuality-ctl" +NODE_ENV = Path(os.environ.get("VIRTUALITY_NODE_ENV", str(CONFIG_DIR / "web.env"))) +SETUP_STATE = CONFIG_DIR / "setup.json" +SETUP_DONE = CONFIG_DIR / "setup_done" +WIZARD_FILE = CONFIG_DIR / "wizard.json" +FIRSTBOOT_LOG = Path("/var/log/virtuality/firstboot.log") +MIN_SPARE_DISK = 16 * 1024 ** 3 + +POPULAR_TIMEZONES = [ + "Europe/Moscow", "Europe/Kaliningrad", "Europe/Samara", "Asia/Yekaterinburg", "Asia/Omsk", "Asia/Novosibirsk", + "Asia/Krasnoyarsk", "Asia/Irkutsk", "Asia/Yakutsk", "Asia/Vladivostok", "Asia/Magadan", "Asia/Kamchatka", + "Europe/Minsk", "Europe/Kiev", "Asia/Almaty", "Asia/Tashkent", "Asia/Tbilisi", "Asia/Yerevan", "Asia/Baku", + "Europe/Berlin", "Europe/London", "Europe/Istanbul", "Asia/Dubai", "America/New_York", "UTC", +] + + +# ---------------------------------------------------------------- node settings +def node_settings() -> dict[str, str]: + data: dict[str, str] = {} + try: + for raw in NODE_ENV.read_text().splitlines(): + line = raw.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + data[key.strip()] = value.strip().strip('"').strip("'") + except OSError: + pass + data.setdefault("VIRTUALITY_WEB_PORT", "8088") + data.setdefault("VIRTUALITY_TLS_PORT", "8443") + data.setdefault("VIRTUALITY_TLS", "0") + data.setdefault("VIRTUALITY_AUTO_UPDATE", "1") + data.setdefault("VIRTUALITY_UPDATE_CHANNEL", "stable") + return data + + +def panel_url(host: str, settings: dict[str, str] | None = None) -> str: + settings = settings or node_settings() + if settings.get("VIRTUALITY_TLS") == "1": + return f"https://{host}:{settings['VIRTUALITY_TLS_PORT']}" + return f"http://{host}:{settings['VIRTUALITY_WEB_PORT']}" + + +def ctl_available() -> bool: + return Path(CTL).exists() + + +def ctl(*args: str, timeout: int = 60, stdin: str | None = None) -> dict[str, Any]: + if not ctl_available(): + return {"ok": False, "code": -1, "stdout": "", "stderr": "virtuality-ctl не установлен", "cmd": CTL} + try: + result = subprocess.run([CTL, *args], capture_output=True, text=True, timeout=timeout, check=False, input=stdin) + return {"ok": result.returncode == 0, "code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "cmd": " ".join([CTL, *args])} + except Exception as exc: + return {"ok": False, "code": -1, "stdout": "", "stderr": str(exc), "cmd": " ".join([CTL, *args])} + + +def ctl_detached(*args: str, delay_seconds: int = 2) -> dict[str, Any]: + """Run a virtuality-ctl command after the HTTP response is sent (the command may restart the panel).""" + unit = f"virtuality-apply-{datetime.now().strftime('%Y%m%d%H%M%S%f')}" + return run_cmd(["systemd-run", "--unit", unit, "--collect", "--quiet", f"--on-active={delay_seconds}", CTL, *args], timeout=15) + + +# ---------------------------------------------------------------- first boot state +def setup_state() -> dict[str, Any]: + try: + data = json.loads(SETUP_STATE.read_text()) + except Exception: + data = {} + data.setdefault("stage", "done" if not SETUP_STATE.exists() else "installing") + data.setdefault("steps", []) + data.setdefault("message", "") + data["installed"] = data["stage"] == "done" + data["log_tail"] = "" + if FIRSTBOOT_LOG.exists(): + try: + data["log_tail"] = "\n".join(FIRSTBOOT_LOG.read_text(errors="replace").splitlines()[-40:]) + except OSError: + pass + return data + + +def wizard_done() -> bool: + return SETUP_DONE.exists() + + +def mark_wizard_done() -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + SETUP_DONE.write_text(datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n") + + +def wizard_choices() -> dict[str, Any]: + try: + return json.loads(WIZARD_FILE.read_text()) + except Exception: + return {} + + +def save_wizard_choices(**changes: Any) -> dict[str, Any]: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + data = wizard_choices() + data.update(changes) + WIZARD_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + return data + + +# ---------------------------------------------------------------- hardware & time +def hardware_summary() -> dict[str, Any]: + cpu_model = "—" + try: + for line in Path("/proc/cpuinfo").read_text().splitlines(): + key, _, value = line.partition(":") + if key.strip().lower() == "model name" and value.strip(): + cpu_model = value.strip() + break + except OSError: + pass + mem_total = 0 + try: + for line in Path("/proc/meminfo").read_text().splitlines(): + if line.startswith("MemTotal:"): + mem_total = int(line.split()[1]) * 1024 + except (OSError, ValueError): + pass + try: + usage = shutil.disk_usage(str(STORAGE_DIR) if STORAGE_DIR.exists() else "/") + disk_total, disk_free = usage.total, usage.free + except OSError: + disk_total = disk_free = 0 + return { + "hostname": run_cmd(["hostname"])["stdout"] or "—", + "cpu_model": cpu_model, + "cpu_count": os.cpu_count() or 1, + "mem_total": mem_total, + "disk_total": disk_total, + "disk_free": disk_free, + "kvm": Path("/dev/kvm").exists(), + } + + +def timezones() -> dict[str, Any]: + current = run_cmd(["timedatectl", "show", "-p", "Timezone", "--value"])["stdout"] or "UTC" + listed = run_cmd(["timedatectl", "list-timezones"], timeout=10) + names = [line.strip() for line in listed["stdout"].splitlines() if "/" in line or line.strip() == "UTC"] if listed["ok"] else [] + if not names: + names = list(POPULAR_TIMEZONES) + popular = [tz for tz in POPULAR_TIMEZONES if tz in names] + return {"current": current, "popular": popular, "all": names} + + +def set_timezone(tz: str) -> tuple[bool, str]: + if not re.fullmatch(r"[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+){0,2}", tz or ""): + return False, "Некорректный часовой пояс" + result = ctl("timezone", tz, timeout=30) + return result["ok"], cmd_error(result, "Не удалось сменить часовой пояс") if not result["ok"] else tz + + +# ---------------------------------------------------------------- network facts +def _ip_json(*args: str) -> list[dict[str, Any]]: + result = run_cmd(["ip", "-j", *args], timeout=8) + if not result["ok"]: + return [] + try: + data = json.loads(result["stdout"] or "[]") + return data if isinstance(data, list) else [] + except json.JSONDecodeError: + return [] + + +def network_facts() -> dict[str, Any]: + routes = _ip_json("route", "show", "default") + default = routes[0] if routes else {} + iface = default.get("dev", "") + gateway = default.get("gateway", "") + address = "" + prefix = 0 + mac = "" + for entry in _ip_json("addr", "show", iface) if iface else []: + mac = entry.get("address", "") or mac + for info in entry.get("addr_info", []): + if info.get("family") == "inet" and not address: + address = info.get("local", "") + prefix = int(info.get("prefixlen", 0) or 0) + wireless = bool(iface) and Path(f"/sys/class/net/{iface}/wireless").exists() + bridge_present = Path("/sys/class/net/br0").exists() + on_bridge = iface == "br0" + private = False + try: + private = ipaddress.ip_address(address).is_private if address else False + except ValueError: + private = False + is_vps = bool(address) and not private + virt = run_cmd(["systemd-detect-virt"], timeout=5)["stdout"] + if virt and virt != "none": + is_vps = True + if on_bridge: + recommended = "bridge" + elif wireless or is_vps or not iface: + recommended = "nat" + else: + recommended = "bridge" + revert_armed = run_cmd(["systemctl", "is-active", "virtuality-netplan-revert.timer"], timeout=5)["stdout"] == "active" + return { + "interface": iface, "gateway": gateway, "address": address, "prefix": prefix, "mac": mac, + "wireless": wireless, "private": private, "is_vps": is_vps, "virt": virt or "none", + "bridge_present": bridge_present, "on_bridge": on_bridge, "recommended": recommended, + "revert_armed": revert_armed, + "bridge_possible": bool(iface) and not wireless and not on_bridge, + } + + +def enable_bridge(iface: str, mode: str = "dhcp") -> tuple[bool, str]: + if not re.fullmatch(r"[a-zA-Z0-9_.:-]{1,15}", iface or "") or mode not in ("dhcp", "static"): + return False, "Некорректный интерфейс" + result = ctl_detached("bridge", iface, mode, delay_seconds=1) + return result["ok"], cmd_error(result, "Не удалось запустить настройку сети") if not result["ok"] else "Сеть перенастраивается" + + +def confirm_bridge() -> tuple[bool, str]: + result = ctl("bridge-confirm", timeout=30) + return result["ok"], cmd_error(result, "Не удалось подтвердить сеть") if not result["ok"] else "Сеть подтверждена" + + +def revert_bridge() -> tuple[bool, str]: + result = ctl_detached("bridge-revert", delay_seconds=1) + return result["ok"], cmd_error(result, "Не удалось вернуть прежнюю сеть") if not result["ok"] else "Возвращаем прежнюю сеть" + + +# ---------------------------------------------------------------- storage facts +def _lsblk() -> list[dict[str, Any]]: + result = run_cmd(["lsblk", "-J", "-b", "-o", "NAME,PATH,TYPE,SIZE,FSTYPE,MOUNTPOINT,MODEL,TRAN,RM,PKNAME"], timeout=10) + if not result["ok"]: + return [] + try: + return json.loads(result["stdout"] or "{}").get("blockdevices", []) + except json.JSONDecodeError: + return [] + + +def spare_disks() -> list[dict[str, Any]]: + """Whole disks with no partitions or file system: safe candidates for VM storage.""" + disks = [] + for dev in _lsblk(): + if dev.get("type") != "disk" or dev.get("children") or dev.get("fstype") or dev.get("mountpoint"): + continue + if str(dev.get("rm")) in ("1", "True", "true") or dev.get("tran") == "usb": + continue + size = int(dev.get("size") or 0) + if size < MIN_SPARE_DISK: + continue + disks.append({"path": dev.get("path") or f"/dev/{dev.get('name')}", "name": dev.get("name"), "size": size, "model": (dev.get("model") or "").strip() or "Диск", "tran": dev.get("tran") or ""}) + return disks + + +def storage_facts() -> dict[str, Any]: + try: + usage = shutil.disk_usage(str(STORAGE_DIR) if STORAGE_DIR.exists() else "/") + total, free = usage.total, usage.free + except OSError: + total = free = 0 + mount = run_cmd(["findmnt", "-n", "-o", "SOURCE,TARGET", "--target", str(STORAGE_DIR)], timeout=5)["stdout"] + source, _, target = mount.partition(" ") + return {"path": str(STORAGE_DIR), "total": total, "free": free, "source": source, "mount_target": target.strip() or "/", "dedicated": target.strip() == str(STORAGE_DIR), "spare": spare_disks()} + + +def use_disk_for_storage(path: str) -> tuple[bool, str]: + if not any(d["path"] == path for d in spare_disks()): + return False, "Этот диск нельзя использовать: он занят или на нём уже есть данные" + result = ctl("storage-use", path, timeout=600) + return result["ok"], cmd_error(result, "Не удалось подготовить диск") if not result["ok"] else "Диск подключён для хранения машин" + + +# ---------------------------------------------------------------- account & settings +def change_password(user: str, new_password: str) -> tuple[bool, str]: + if len(new_password) < 6: + return False, "Пароль должен быть не короче 6 символов" + result = ctl("passwd", user, stdin=new_password + "\n", timeout=30) + return result["ok"], cmd_error(result, "Не удалось сменить пароль") if not result["ok"] else "Пароль изменён" + + +def apply_settings(changes: dict[str, str]) -> tuple[bool, str]: + allowed = {"VIRTUALITY_WEB_PORT", "VIRTUALITY_TLS", "VIRTUALITY_TLS_PORT", "VIRTUALITY_AUTO_UPDATE", "VIRTUALITY_UPDATE_CHANNEL"} + pairs = [f"{key}={value}" for key, value in changes.items() if key in allowed and re.fullmatch(r"[A-Za-z0-9._-]+", str(value))] + if not pairs: + return False, "Нечего применять" + result = ctl_detached("set", *pairs) + return result["ok"], cmd_error(result, "Не удалось применить настройки") if not result["ok"] else "Настройки применяются" + + +def power(action: str) -> tuple[bool, str]: + if action not in ("reboot", "poweroff"): + return False, "Неизвестное действие" + result = ctl_detached("power", action, delay_seconds=3) + return result["ok"], cmd_error(result, "Не удалось выполнить") if not result["ok"] else ("Сервер перезагружается" if action == "reboot" else "Сервер выключается") + + +def backup_config() -> tuple[bool, str]: + result = ctl("backup-config", timeout=120) + return result["ok"], cmd_error(result, "Не удалось создать архив настроек") if not result["ok"] else result["stdout"] diff --git a/web/static/app.css b/web/static/app.css index 33b98fa..c004676 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -584,3 +584,31 @@ details.advanced > .advanced-body { padding: 4px 16px 16px; } .hide-sm { display: none !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } + +/* ---------------------------------------------------------- setup wizard */ +.setup-page { background: var(--bg); } +.setup-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 32px; border-bottom: 1px solid var(--border); background: var(--surface); } +.setup-top .logo { padding: 0; } +.setup-main { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 32px; width: 100%; max-width: 1180px; margin: 0 auto; padding: 36px 32px 60px; } +.setup-rail { display: grid; gap: 4px; align-content: start; position: sticky; top: 24px; } +.setup-step { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; color: var(--text-3); } +.setup-step:hover { text-decoration: none; } +.setup-step[href]:hover { background: var(--surface-3); color: var(--text); } +.setup-step strong { display: block; font-weight: 600; color: inherit; } +.setup-step small { display: block; font-size: 12px; } +.setup-step-num { display: grid; place-items: center; width: 28px; height: 28px; flex: none; border-radius: 50%; border: 1.5px solid var(--border-strong); font-size: 12.5px; font-weight: 700; } +.setup-step.active { color: var(--text); } +.setup-step.active .setup-step-num { border-color: var(--brand); background: var(--brand); color: #fff; box-shadow: 0 0 0 4px var(--brand-soft); } +.setup-step.done { color: var(--text-2); } +.setup-step.done .setup-step-num { border-color: var(--success); background: var(--success-soft); color: var(--success); } +.setup-content { min-width: 0; } +.setup-card { box-shadow: var(--shadow-md); } +.setup-card .card-header h2 { font-size: 20px; } +.setup-card .card-header p { font-size: 14px; color: var(--text-2); margin-top: 4px; } +@media (max-width: 900px) { + .setup-top { padding: 12px 16px; } + .setup-main { grid-template-columns: 1fr; padding: 20px 16px 48px; gap: 16px; } + .setup-rail { position: static; display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; } + .setup-step { flex: none; padding: 6px 10px; } + .setup-step small { display: none; } +} diff --git a/web/static/panel.js b/web/static/panel.js index 5581d03..e67ae91 100644 --- a/web/static/panel.js +++ b/web/static/panel.js @@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); + const icon = (name) => ``; + 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' ? '' : icon('circle-dot'); + const detail = item.status === 'running' && state.message ? `${escapeHtml(state.message)}` : ''; + return `
${mark}
${escapeHtml(item.title)}${detail}
`; + }).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); + }); +})(); diff --git a/web/templates/setup.html b/web/templates/setup.html new file mode 100644 index 0000000..ce2b8f1 --- /dev/null +++ b/web/templates/setup.html @@ -0,0 +1,241 @@ +{% extends "setup_base.html" %} +{% from "_ui.html" import icon, badge, alert, meter %} + +{% block step %} +{# ============================================================ install #} +{% if step == 'install' %} +

Настраиваем сервер

Устанавливаются компоненты виртуализации. Обычно это занимает 5–15 минут — можно оставить страницу открытой, дальше всё произойдёт само.

+
+ {% if state.stage == 'error' %} + {{ alert('danger', state.message or 'Установка остановилась с ошибкой. Она будет повторена автоматически через минуту.', 'Что-то пошло не так') }} + {% elif state.stage == 'waiting-network' %} + {{ alert('warning', 'Подключите сервер к интернету кабелем или настройте Wi-Fi — установка продолжится сама, ничего нажимать не нужно.', 'Нет доступа в интернет') }} + {% endif %} +
+ {% for item in state.steps %} +
+ {% if item.status == 'done' %}{{ icon('circle-check') }}{% elif item.status == 'error' %}{{ icon('circle-x') }}{% elif item.status == 'running' %}{% else %}{{ icon('circle-dot') }}{% endif %} +
{{ item.title }}{% if item.status == 'running' and state.message %}{{ state.message }}{% endif %}
+
+ {% else %} +
Подготовка…
+ {% endfor %} +
+
+ {{ icon('terminal', 'icon-sm') }}Что происходит{{ icon('chevron-down', 'icon-sm') }} +
{{ state.log_tail or 'Журнал пока пуст.' }}
+
+
+ + +{# ============================================================ welcome #} +{% elif step == 'welcome' %} +

Добро пожаловать в Virtuality

Проверим сервер и зададим время. Дальше — пять коротких шагов.

+
+
+
+
Сервер{{ hardware.hostname }}
+
Процессор{{ hardware.cpu_count }} {{ plural(hardware.cpu_count, 'ядро', 'ядра', 'ядер') }}
+
Память{{ hardware.mem_total|format_bytes }}
+
Свободно для машин{{ hardware.disk_free|format_bytes }}
+
+ {% if hardware.kvm %} + {{ alert('success', 'Аппаратное ускорение включено — машины будут работать быстро.', 'Виртуализация готова') }} + {% else %} + {{ alert('warning', 'Машины будут работать очень медленно. Перезагрузите сервер, войдите в BIOS/UEFI (обычно клавиша Del или F2) и включите Intel VT-x / AMD-V (может называться SVM Mode или Virtualization Technology). Продолжить можно и без этого.', 'Аппаратное ускорение выключено') }} + {% endif %} +
+ + + Сейчас на сервере: {{ timezones.current }}. От пояса зависят время в журналах и час ночного обновления. +
+
+ +
+ +{# ============================================================ account #} +{% elif step == 'account' %} +

Администратор

В панель входят по имени и паролю пользователя сервера. Пароль нигде не сохраняется.

+
+
+
{{ icon('user') }}
{{ auth_user }}Пользователь для входа в панель и по SSH
{{ badge('Администратор', 'brand', False) }}
+
+ {{ icon('lock', 'icon-sm') }}Сменить пароль{{ icon('chevron-down', 'icon-sm') }} +
+
+
+
+
+ Не короче 6 символов. Этот же пароль будет действовать для SSH и консоли сервера. +
+
+

{{ icon('info', 'icon-sm') }} Если пароль забудется, его можно сбросить с монитора и клавиатуры сервера: sudo virtuality-ctl passwd.

+
+ +
+ +{# ============================================================ access #} +{% elif step == 'access' %} +

Доступ к панели

Как вы будете открывать Virtuality в браузере.

+
+
+
+ + +
+
+ {{ icon('wrench', 'icon-sm') }}Порт панели{{ icon('chevron-down', 'icon-sm') }} +
+
Стандартный — 8088. Меняйте, только если он занят.
+

С HTTPS панель отвечает на порту 8443, а по обычному адресу автоматически перенаправляет на защищённый.

+
+
+

{{ icon('info', 'icon-sm') }} Настройки применятся на последнем шаге, после чего панель перезапустится и откроется по новому адресу.

+
+ +
+ +{# ============================================================ network #} +{% elif step == 'network' %} +

Сеть машин

Как виртуальные машины будут видны в сети. Это можно изменить позже в разделе «Сеть и доступ».

+
+
+ {% if net.on_bridge %} + {{ alert('success', 'Машины будут получать адреса от вашего роутера, как обычные устройства.', 'Сервер уже подключён через мост br0') }} + {% elif net.wireless %} + {{ alert('info', 'Сервер подключён по Wi-Fi, поэтому машины не могут появиться в домашней сети напрямую — они выйдут в интернет через сервер (NAT). Подключите кабель, если нужен режим домашней сети.') }} + {% elif net.is_vps %} + {{ alert('info', 'Похоже, это VPS или облачный сервер: у него публичный адрес. Машины будут скрыты за сервером, а нужные сервисы вы откроете наружу в разделе «Сеть и доступ».') }} + {% endif %} +
+ + +
+ {% if net.bridge_possible %} +
{{ icon('info') }}
Как включается домашняя сетьИнтерфейс {{ net.interface }} ({{ net.address or 'адрес по DHCP' }}) объединится в мост br0 с тем же MAC-адресом, поэтому адрес сервера не изменится. Если панель не ответит в течение 2 минут, сервер сам вернёт прежнюю сеть.
+ {% endif %} +
+ +
+ +{# ============================================================ storage #} +{% elif step == 'storage' %} +

Хранилище

Где будут лежать диски машин, образы и резервные копии.

+
+
+
+
Папка{{ storage.path }}
+
Свободно{{ storage.free|format_bytes }} из {{ storage.total|format_bytes }}
+
Диск{{ storage.source or 'системный' }}{% if storage.dedicated %} (отдельный){% endif %}
+
+ {% if storage.spare %} +
{{ icon('sparkles') }}
Найден свободный дискЕго можно отдать под машины целиком: системный диск останется для Ubuntu, а место для машин будет отдельным.
+
+ + {% for disk in storage.spare %} + + {% endfor %} +
+ {% else %} +

Свободных дисков не найдено — машины будут храниться на системном диске. Если позже добавите диск, его можно подключить в Настройках.

+ {% endif %} +
+ +
+ +{# ============================================================ updates #} +{% elif step == 'updates' %} +

Обновления

Virtuality умеет обновляться сама. Машины во время обновления продолжают работать.

+
+
+
+ + +
+
+ {{ icon('layers', 'icon-sm') }}Канал обновлений{{ icon('chevron-down', 'icon-sm') }} +
+ +
+
+
+ +
+ +{# ============================================================ finish #} +{% elif step == 'finish' %} +

Всё готово

Проверьте и нажмите «Завершить». {% if restart_needed %}Панель перезапустится и откроется по новому адресу.{% endif %}

+
+
+
+
Адрес панели{{ final_url }}
+
Администратор{{ auth_user }}{% if choices.get('password_changed') %} · пароль обновлён{% endif %}
+
Защищённое соединение{{ 'Включено (HTTPS)' if choices.get('tls', '1')|string == '1' else 'Выключено' }}
+
Сеть машин{{ 'Домашняя сеть (мост br0)' if choices.get('network') == 'bridge' or net.on_bridge else 'Отдельная сеть (NAT)' }}
+
Хранилище{{ storage.free|format_bytes }} свободно{% if storage.dedicated %} · отдельный диск{% endif %}
+
Обновления{{ 'Автоматически' if choices.get('auto_update', settings.VIRTUALITY_AUTO_UPDATE)|string == '1' else 'Вручную' }}{% if choices.get('channel', settings.VIRTUALITY_UPDATE_CHANNEL) == 'main' %} · ранний доступ{% endif %}
+
Часовой пояс{{ timezone }}
+
Ускорение{{ 'KVM включён' if hardware.kvm else 'Выключено — машины будут медленными' }}
+
+ {% if choices.get('tls', '1')|string == '1' %} +
{{ icon('shield-check') }}
При первом открытии по HTTPS браузер предупредит о сертификатеНажмите «Дополнительно» → «Перейти на сайт» (в Chrome) или «Принять риск» (в Firefox). Это происходит один раз.
+ {% endif %} +

Что дальше: загрузите установочный образ или выберите готовую систему из каталога и создайте первую машину. Все настройки можно изменить в разделе «Настройки».

+
+ +
+{% endif %} +{% endblock %} diff --git a/web/templates/setup_applying.html b/web/templates/setup_applying.html new file mode 100644 index 0000000..bd327d8 --- /dev/null +++ b/web/templates/setup_applying.html @@ -0,0 +1,30 @@ +{% extends "setup_base.html" %} +{% from "_ui.html" import icon, alert %} +{% block rail %}{% endblock %} +{% block step %} +

Применяем настройки

Панель перезапускается — это занимает несколько секунд.

+
+
Перезапускаем панель…
+
+
Новый адрес панели{{ final_url }}
+ {% if tls %} +
{{ icon('shield-check') }}
Браузер предупредит о сертификатеСертификат создан этим сервером, поэтому браузер его не знает. Нажмите «Дополнительно» → «Перейти на сайт» (Chrome) или «Принять риск и продолжить» (Firefox). Дальше предупреждение не появится.
+ {% endif %} +

Сохраните новый адрес в закладки. Если страница не открылась сама через 15 секунд, перейдите по ссылке выше.

+
+ +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/web/templates/setup_base.html b/web/templates/setup_base.html new file mode 100644 index 0000000..45b4c19 --- /dev/null +++ b/web/templates/setup_base.html @@ -0,0 +1,60 @@ +{% from "_ui.html" import icon, logo, alert %} + + + + + + + {{ page_title|default('Мастер настройки') }} — Virtuality + + + + + +
+ +
+ {{ user }} + +
+
+
+ {% block rail %} + {% if steps is defined %} + + {% endif %} + {% endblock %} +
+ {% if error %}
{{ alert('danger', error) }}
{% endif %} + {% if message %}
{{ alert('success', message) }}
{% endif %} +
+ {% block step %}{% endblock %} +
+
+
+ +
+
{{ icon('triangle-alert', 'icon-lg') }}

Подтвердите действие

+
+
+
+
+ + {% block scripts %}{% endblock %} + + diff --git a/web/templates/setup_bridge_wait.html b/web/templates/setup_bridge_wait.html new file mode 100644 index 0000000..4dc51ea --- /dev/null +++ b/web/templates/setup_bridge_wait.html @@ -0,0 +1,48 @@ +{% extends "setup_base.html" %} +{% from "_ui.html" import icon, alert %} +{% block rail %}{% endblock %} +{% block step %} +

Перенастраиваем сеть

Сервер объединяет сетевой порт в мост br0. Это займёт около 20 секунд, страница не перезагружается.

+
+
Ждём, пока сервер снова ответит…
+
+
{{ icon('info') }}
Если страница так и не оживётНичего страшного: через 2 минуты сервер сам вернёт прежнюю сеть. Обновите страницу и выберите «Отдельная сеть (NAT)» или попробуйте ещё раз.
+
+ +{% endblock %} +{% block scripts %} + +{% endblock %}