Мастер первичной настройки и 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 удалений
+6
Просмотреть файл
@@ -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)
Просмотреть файл
+287
Просмотреть файл
@@ -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)
+313
Просмотреть файл
@@ -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"]
+28
Просмотреть файл
@@ -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; }
}
+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);
});
})();
+241
Просмотреть файл
@@ -0,0 +1,241 @@
{% extends "setup_base.html" %}
{% from "_ui.html" import icon, badge, alert, meter %}
{% block step %}
{# ============================================================ install #}
{% if step == 'install' %}
<div class="card-header"><div><h2>Настраиваем сервер</h2><p>Устанавливаются компоненты виртуализации. Обычно это занимает 5–15 минут — можно оставить страницу открытой, дальше всё произойдёт само.</p></div></div>
<div class="card-body stack" data-setup-install data-installed="{{ '1' if state.installed else '0' }}" data-next="/setup/welcome">
{% if state.stage == 'error' %}
{{ alert('danger', state.message or 'Установка остановилась с ошибкой. Она будет повторена автоматически через минуту.', 'Что-то пошло не так') }}
{% elif state.stage == 'waiting-network' %}
{{ alert('warning', 'Подключите сервер к интернету кабелем или настройте Wi-Fi — установка продолжится сама, ничего нажимать не нужно.', 'Нет доступа в интернет') }}
{% endif %}
<div class="checklist" data-setup-steps>
{% for item in state.steps %}
<div class="check-item {{ 'success' if item.status == 'done' else 'danger' if item.status == 'error' else 'warning' if item.status == 'running' else '' }}">
{% if item.status == 'done' %}{{ icon('circle-check') }}{% elif item.status == 'error' %}{{ icon('circle-x') }}{% elif item.status == 'running' %}<span class="spinner" style="width:18px;height:18px;margin-top:2px"></span>{% else %}{{ icon('circle-dot') }}{% endif %}
<div><strong>{{ item.title }}</strong>{% if item.status == 'running' and state.message %}<span>{{ state.message }}</span>{% endif %}</div>
</div>
{% else %}
<div class="check-item"><span class="spinner" style="width:18px;height:18px"></span><div><strong>Подготовка…</strong></div></div>
{% endfor %}
</div>
<details class="advanced">
<summary>{{ icon('terminal', 'icon-sm') }}Что происходит<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body"><pre class="terminal" style="max-height:260px" data-setup-log>{{ state.log_tail or 'Журнал пока пуст.' }}</pre></div>
</details>
</div>
<div class="card-footer">
<span class="subtle small" style="margin-right:auto" data-setup-status>{{ 'Всё установлено' if state.installed else 'Продолжим автоматически, когда установка завершится' }}</span>
<a class="btn btn-primary {{ '' if state.installed else 'disabled' }}" href="/setup/welcome" data-setup-next>Продолжить{{ icon('arrow-right') }}</a>
</div>
{# ============================================================ welcome #}
{% elif step == 'welcome' %}
<div class="card-header"><div><h2>Добро пожаловать в Virtuality</h2><p>Проверим сервер и зададим время. Дальше — пять коротких шагов.</p></div></div>
<form method="post" action="/setup/welcome">
<div class="card-body stack">
<div class="kv">
<div><span>Сервер</span><strong>{{ hardware.hostname }}</strong></div>
<div><span>Процессор</span><strong>{{ hardware.cpu_count }} {{ plural(hardware.cpu_count, 'ядро', 'ядра', 'ядер') }}</strong></div>
<div><span>Память</span><strong>{{ hardware.mem_total|format_bytes }}</strong></div>
<div><span>Свободно для машин</span><strong>{{ hardware.disk_free|format_bytes }}</strong></div>
</div>
{% if hardware.kvm %}
{{ alert('success', 'Аппаратное ускорение включено — машины будут работать быстро.', 'Виртуализация готова') }}
{% else %}
{{ alert('warning', 'Машины будут работать очень медленно. Перезагрузите сервер, войдите в BIOS/UEFI (обычно клавиша Del или F2) и включите Intel VT-x / AMD-V (может называться SVM Mode или Virtualization Technology). Продолжить можно и без этого.', 'Аппаратное ускорение выключено') }}
{% endif %}
<div class="field">
<label for="timezone">Часовой пояс</label>
<select class="select" id="timezone" name="timezone">
<optgroup label="Популярные">
{% for tz in timezones.popular %}<option value="{{ tz }}" {% if tz == timezones.current %}selected{% endif %}>{{ tz }}</option>{% endfor %}
</optgroup>
<optgroup label="Все">
{% for tz in timezones.all %}<option value="{{ tz }}" {% if tz == timezones.current %}selected{% endif %}>{{ tz }}</option>{% endfor %}
</optgroup>
</select>
<span class="hint">Сейчас на сервере: {{ timezones.current }}. От пояса зависят время в журналах и час ночного обновления.</span>
</div>
</div>
<div class="card-footer"><a class="btn btn-ghost" href="/setup/skip" style="margin-right:auto">Пропустить мастер</a><button class="btn btn-primary">Продолжить{{ icon('arrow-right') }}</button></div>
</form>
{# ============================================================ account #}
{% elif step == 'account' %}
<div class="card-header"><div><h2>Администратор</h2><p>В панель входят по имени и паролю пользователя сервера. Пароль нигде не сохраняется.</p></div></div>
<form method="post" action="/setup/account">
<div class="card-body stack">
<div class="upload-file"><span class="list-icon brand">{{ icon('user') }}</span><div class="grow"><strong>{{ auth_user }}</strong><span class="subtle small" style="display:block">Пользователь для входа в панель и по SSH</span></div>{{ badge('Администратор', 'brand', False) }}</div>
<details class="advanced" {% if error %}open{% endif %}>
<summary>{{ icon('lock', 'icon-sm') }}Сменить пароль<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body form-grid">
<div class="form-row">
<div class="field"><label for="password">Новый пароль</label><input class="input" id="password" type="password" name="password" minlength="6" autocomplete="new-password"></div>
<div class="field"><label for="password2">Ещё раз</label><input class="input" id="password2" type="password" name="password2" minlength="6" autocomplete="new-password"></div>
</div>
<span class="hint">Не короче 6 символов. Этот же пароль будет действовать для SSH и консоли сервера.</span>
</div>
</details>
<p class="subtle small">{{ icon('info', 'icon-sm') }} Если пароль забудется, его можно сбросить с монитора и клавиатуры сервера: <code>sudo virtuality-ctl passwd</code>.</p>
</div>
<div class="card-footer"><a class="btn btn-ghost" href="/setup/welcome" style="margin-right:auto">{{ icon('arrow-left') }}Назад</a><button class="btn btn-primary">Продолжить{{ icon('arrow-right') }}</button></div>
</form>
{# ============================================================ access #}
{% elif step == 'access' %}
<div class="card-header"><div><h2>Доступ к панели</h2><p>Как вы будете открывать Virtuality в браузере.</p></div></div>
<form method="post" action="/setup/access">
<div class="card-body stack">
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(240px,1fr))">
<label class="option">
<input type="radio" name="tls" value="1" {% if tls %}checked{% endif %}>
<span class="option-body">
<span class="option-top"><span class="option-icon">{{ icon('shield-check') }}</span>{{ badge('Рекомендуем', 'brand', False) }}</span>
<span class="option-title">Защищённое соединение (HTTPS)</span>
<span class="option-text">Пароль и данные шифруются. Сертификат создаётся на сервере, поэтому браузер один раз спросит, доверять ли ему — это нормально, нажмите «Продолжить».</span>
</span>
</label>
<label class="option">
<input type="radio" name="tls" value="0" {% if not tls %}checked{% endif %}>
<span class="option-body">
<span class="option-top"><span class="option-icon">{{ icon('globe') }}</span><span class="option-check"></span></span>
<span class="option-title">Обычное соединение (HTTP)</span>
<span class="option-text">Без предупреждений браузера. Подходит только внутри домашней сети, которой вы доверяете.</span>
</span>
</label>
</div>
<details class="advanced">
<summary>{{ icon('wrench', 'icon-sm') }}Порт панели<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body form-grid">
<div class="field" style="max-width:220px"><label for="port">Порт</label><input class="input" id="port" type="number" name="port" min="1" max="65535" value="{{ port }}"><span class="hint">Стандартный — 8088. Меняйте, только если он занят.</span></div>
<p class="subtle small">С HTTPS панель отвечает на порту 8443, а по обычному адресу автоматически перенаправляет на защищённый.</p>
</div>
</details>
<p class="subtle small">{{ icon('info', 'icon-sm') }} Настройки применятся на последнем шаге, после чего панель перезапустится и откроется по новому адресу.</p>
</div>
<div class="card-footer"><a class="btn btn-ghost" href="/setup/account" style="margin-right:auto">{{ icon('arrow-left') }}Назад</a><button class="btn btn-primary">Продолжить{{ icon('arrow-right') }}</button></div>
</form>
{# ============================================================ network #}
{% elif step == 'network' %}
<div class="card-header"><div><h2>Сеть машин</h2><p>Как виртуальные машины будут видны в сети. Это можно изменить позже в разделе «Сеть и доступ».</p></div></div>
<form method="post" action="/setup/network">
<div class="card-body stack">
{% if net.on_bridge %}
{{ alert('success', 'Машины будут получать адреса от вашего роутера, как обычные устройства.', 'Сервер уже подключён через мост br0') }}
{% elif net.wireless %}
{{ alert('info', 'Сервер подключён по Wi-Fi, поэтому машины не могут появиться в домашней сети напрямую — они выйдут в интернет через сервер (NAT). Подключите кабель, если нужен режим домашней сети.') }}
{% elif net.is_vps %}
{{ alert('info', 'Похоже, это VPS или облачный сервер: у него публичный адрес. Машины будут скрыты за сервером, а нужные сервисы вы откроете наружу в разделе «Сеть и доступ».') }}
{% endif %}
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(240px,1fr))">
<label class="option">
<input type="radio" name="mode" value="nat" {% if net.recommended == 'nat' and not net.on_bridge %}checked{% endif %}>
<span class="option-body">
<span class="option-top"><span class="option-icon">{{ icon('shield-check') }}</span>{% if net.recommended == 'nat' %}{{ badge('Рекомендуем', 'brand', False) }}{% else %}<span class="option-check"></span>{% endif %}</span>
<span class="option-title">Отдельная сеть (NAT)</span>
<span class="option-text">Машины получают адреса 192.168.100.x и выходят в интернет через сервер. Снаружи их не видно, пока вы сами не откроете доступ. Работает везде.</span>
</span>
</label>
<label class="option">
<input type="radio" name="mode" value="bridge" {% if net.recommended == 'bridge' or net.on_bridge %}checked{% endif %} {% if not net.bridge_possible and not net.on_bridge %}disabled{% endif %}>
<span class="option-body">
<span class="option-top"><span class="option-icon">{{ icon('network') }}</span>{% if net.recommended == 'bridge' %}{{ badge('Рекомендуем', 'brand', False) }}{% else %}<span class="option-check"></span>{% endif %}</span>
<span class="option-title">Домашняя сеть (мост)</span>
<span class="option-text">Машины становятся устройствами вашей сети: адрес от роутера, видны с телефона и ноутбука, работают Home Assistant, медиасерверы, принтеры.{% if not net.bridge_possible and not net.on_bridge %} Недоступно: {{ 'сервер на Wi-Fi' if net.wireless else 'проводной интерфейс не найден' }}.{% endif %}</span>
</span>
</label>
</div>
{% if net.bridge_possible %}
<div class="alert info">{{ icon('info') }}<div><strong>Как включается домашняя сеть</strong>Интерфейс {{ net.interface }} ({{ net.address or 'адрес по DHCP' }}) объединится в мост br0 с тем же MAC-адресом, поэтому адрес сервера не изменится. Если панель не ответит в течение 2 минут, сервер сам вернёт прежнюю сеть.</div></div>
{% endif %}
</div>
<div class="card-footer"><a class="btn btn-ghost" href="/setup/access" style="margin-right:auto">{{ icon('arrow-left') }}Назад</a><button class="btn btn-primary" data-busy="Настраиваем сеть…">Продолжить{{ icon('arrow-right') }}</button></div>
</form>
{# ============================================================ storage #}
{% elif step == 'storage' %}
<div class="card-header"><div><h2>Хранилище</h2><p>Где будут лежать диски машин, образы и резервные копии.</p></div></div>
<form method="post" action="/setup/storage">
<div class="card-body stack">
<div class="kv">
<div><span>Папка</span><strong class="mono">{{ storage.path }}</strong></div>
<div><span>Свободно</span><strong>{{ storage.free|format_bytes }} из {{ storage.total|format_bytes }}</strong></div>
<div><span>Диск</span><strong>{{ storage.source or 'системный' }}{% if storage.dedicated %} (отдельный){% endif %}</strong></div>
</div>
{% if storage.spare %}
<div class="alert brand">{{ icon('sparkles') }}<div><strong>Найден свободный диск</strong>Его можно отдать под машины целиком: системный диск останется для Ubuntu, а место для машин будет отдельным.</div></div>
<div class="options">
<label class="option">
<input type="radio" name="disk" value="keep" checked>
<span class="option-body"><span class="option-top"><span class="option-icon">{{ icon('hard-drive') }}</span><span class="option-check"></span></span><span class="option-title">Оставить как есть</span><span class="option-text">Машины на системном диске: {{ storage.free|format_bytes }} свободно</span></span>
</label>
{% for disk in storage.spare %}
<label class="option">
<input type="radio" name="disk" value="{{ disk.path }}">
<span class="option-body"><span class="option-top"><span class="option-icon">{{ icon('database') }}</span><span class="option-check"></span></span><span class="option-title">{{ disk.model }} · {{ disk.size|format_bytes }}</span><span class="option-text">{{ disk.path }}{% if disk.tran %} · {{ disk.tran|upper }}{% endif %}. Диск будет отформатирован — все данные на нём пропадут.</span></span>
</label>
{% endfor %}
</div>
{% else %}
<p class="muted">Свободных дисков не найдено — машины будут храниться на системном диске. Если позже добавите диск, его можно подключить в Настройках.</p>
{% endif %}
</div>
<div class="card-footer"><a class="btn btn-ghost" href="/setup/network" style="margin-right:auto">{{ icon('arrow-left') }}Назад</a><button class="btn btn-primary" data-busy="Готовим диск…">Продолжить{{ icon('arrow-right') }}</button></div>
</form>
{# ============================================================ updates #}
{% elif step == 'updates' %}
<div class="card-header"><div><h2>Обновления</h2><p>Virtuality умеет обновляться сама. Машины во время обновления продолжают работать.</p></div></div>
<form method="post" action="/setup/updates">
<div class="card-body stack">
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(240px,1fr))">
<label class="option">
<input type="radio" name="auto_update" value="1" {% if choices.get('auto_update', settings.VIRTUALITY_AUTO_UPDATE) == '1' %}checked{% endif %}>
<span class="option-body"><span class="option-top"><span class="option-icon">{{ icon('refresh-cw') }}</span>{{ badge('Рекомендуем', 'brand', False) }}</span><span class="option-title">Обновлять автоматически</span><span class="option-text">Ночью, около 4 часов по времени сервера. Только выпущенные версии, с проверкой и возможностью отката.</span></span>
</label>
<label class="option">
<input type="radio" name="auto_update" value="0" {% if choices.get('auto_update', settings.VIRTUALITY_AUTO_UPDATE) == '0' %}checked{% endif %}>
<span class="option-body"><span class="option-top"><span class="option-icon">{{ icon('wrench') }}</span><span class="option-check"></span></span><span class="option-title">Только вручную</span><span class="option-text">Панель покажет, что вышла новая версия, а обновите вы сами в разделе «Обновления».</span></span>
</label>
</div>
<details class="advanced">
<summary>{{ icon('layers', 'icon-sm') }}Канал обновлений<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body field" style="max-width:360px">
<select class="select" name="channel">
<option value="stable" {% if choices.get('channel', settings.VIRTUALITY_UPDATE_CHANNEL) == 'stable' %}selected{% endif %}>Стабильный — выпущенные версии</option>
<option value="main" {% if choices.get('channel', settings.VIRTUALITY_UPDATE_CHANNEL) == 'main' %}selected{% endif %}>Ранний доступ — все изменения разработчиков</option>
</select>
</div>
</details>
</div>
<div class="card-footer"><a class="btn btn-ghost" href="/setup/storage" style="margin-right:auto">{{ icon('arrow-left') }}Назад</a><button class="btn btn-primary">Продолжить{{ icon('arrow-right') }}</button></div>
</form>
{# ============================================================ finish #}
{% elif step == 'finish' %}
<div class="card-header"><div><h2>Всё готово</h2><p>Проверьте и нажмите «Завершить». {% if restart_needed %}Панель перезапустится и откроется по новому адресу.{% endif %}</p></div></div>
<form method="post" action="/setup/finish">
<div class="card-body stack">
<div class="card" style="box-shadow:none">
<div class="summary-row" style="padding:12px 16px"><span>Адрес панели</span><strong class="mono">{{ final_url }}</strong></div>
<div class="summary-row" style="padding:12px 16px"><span>Администратор</span><strong>{{ auth_user }}{% if choices.get('password_changed') %} · пароль обновлён{% endif %}</strong></div>
<div class="summary-row" style="padding:12px 16px"><span>Защищённое соединение</span><strong>{{ 'Включено (HTTPS)' if choices.get('tls', '1')|string == '1' else 'Выключено' }}</strong></div>
<div class="summary-row" style="padding:12px 16px"><span>Сеть машин</span><strong>{{ 'Домашняя сеть (мост br0)' if choices.get('network') == 'bridge' or net.on_bridge else 'Отдельная сеть (NAT)' }}</strong></div>
<div class="summary-row" style="padding:12px 16px"><span>Хранилище</span><strong>{{ storage.free|format_bytes }} свободно{% if storage.dedicated %} · отдельный диск{% endif %}</strong></div>
<div class="summary-row" style="padding:12px 16px"><span>Обновления</span><strong>{{ 'Автоматически' if choices.get('auto_update', settings.VIRTUALITY_AUTO_UPDATE)|string == '1' else 'Вручную' }}{% if choices.get('channel', settings.VIRTUALITY_UPDATE_CHANNEL) == 'main' %} · ранний доступ{% endif %}</strong></div>
<div class="summary-row" style="padding:12px 16px"><span>Часовой пояс</span><strong>{{ timezone }}</strong></div>
<div class="summary-row" style="padding:12px 16px"><span>Ускорение</span><strong>{{ 'KVM включён' if hardware.kvm else 'Выключено — машины будут медленными' }}</strong></div>
</div>
{% if choices.get('tls', '1')|string == '1' %}
<div class="alert info">{{ icon('shield-check') }}<div><strong>При первом открытии по HTTPS браузер предупредит о сертификате</strong>Нажмите «Дополнительно» → «Перейти на сайт» (в Chrome) или «Принять риск» (в Firefox). Это происходит один раз.</div></div>
{% endif %}
<p class="subtle small">Что дальше: загрузите установочный образ или выберите готовую систему из каталога и создайте первую машину. Все настройки можно изменить в разделе «Настройки».</p>
</div>
<div class="card-footer"><a class="btn btn-ghost" href="/setup/updates" style="margin-right:auto">{{ icon('arrow-left') }}Назад</a><button class="btn btn-primary btn-lg" data-busy="Применяем…">{{ icon('check') }}Завершить настройку</button></div>
</form>
{% endif %}
{% endblock %}
+30
Просмотреть файл
@@ -0,0 +1,30 @@
{% extends "setup_base.html" %}
{% from "_ui.html" import icon, alert %}
{% block rail %}{% endblock %}
{% block step %}
<div class="card-header"><div><h2>Применяем настройки</h2><p>Панель перезапускается — это занимает несколько секунд.</p></div></div>
<div class="card-body stack" data-applying data-url="{{ final_url }}">
<div class="row" style="gap:14px"><span class="spinner" style="width:22px;height:22px;color:var(--brand)"></span><strong data-applying-status>Перезапускаем панель…</strong></div>
<div class="progress indeterminate"><span></span></div>
<div class="kv"><div><span>Новый адрес панели</span><strong class="mono"><a href="{{ final_url }}">{{ final_url }}</a></strong></div></div>
{% if tls %}
<div class="alert info">{{ icon('shield-check') }}<div><strong>Браузер предупредит о сертификате</strong>Сертификат создан этим сервером, поэтому браузер его не знает. Нажмите «Дополнительно» → «Перейти на сайт» (Chrome) или «Принять риск и продолжить» (Firefox). Дальше предупреждение не появится.</div></div>
{% endif %}
<p class="subtle small">Сохраните новый адрес в закладки. Если страница не открылась сама через 15 секунд, перейдите по ссылке выше.</p>
</div>
<div class="card-footer"><a class="btn btn-primary btn-lg" href="{{ final_url }}">Открыть панель{{ icon('arrow-right') }}</a></div>
{% endblock %}
{% block scripts %}
<script>
(function () {
var url = document.querySelector('[data-applying]').dataset.url;
var status = document.querySelector('[data-applying-status]');
var left = 8;
var timer = setInterval(function () {
left -= 1;
status.textContent = 'Открываем панель по новому адресу через ' + left + ' с…';
if (left <= 0) { clearInterval(timer); location.href = url; }
}, 1000);
})();
</script>
{% endblock %}
+60
Просмотреть файл
@@ -0,0 +1,60 @@
{% from "_ui.html" import icon, logo, alert %}
<!doctype html>
<html lang="ru" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{ page_title|default('Мастер настройки') }} — Virtuality</title>
<script>
(function () {
var mode = 'system';
try { mode = localStorage.getItem('virtualityTheme') || 'system'; } catch (e) {}
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
})();
</script>
<link rel="stylesheet" href="/static/app.css?v={{ app_version }}">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%235a48f5'/%3E%3Cpath d='M9 10l7 13 7-13' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
</head>
<body class="setup-page">
<header class="setup-top">
<a class="logo" href="/"><span class="brand-mark">{{ logo() }}</span><span class="brand-name">Virtuality<small>Мастер настройки</small></span></a>
<div class="row">
<span class="subtle small">{{ user }}</span>
<button type="button" class="btn btn-sm btn-icon btn-ghost" data-theme-toggle aria-label="Сменить тему">{{ icon('moon') }}</button>
</div>
</header>
<main class="setup-main">
{% block rail %}
{% if steps is defined %}
<aside class="setup-rail" aria-label="Шаги">
{% for item in steps %}
{% set idx = loop.index0 %}
<a class="setup-step {{ 'done' if idx < step_index else 'active' if idx == step_index else '' }}" {% if idx < step_index or (state.installed and idx <= step_index) %}href="/setup/{{ item.id }}"{% endif %}>
<span class="setup-step-num">{% if idx < step_index %}{{ icon('check', 'icon-sm') }}{% else %}{{ loop.index }}{% endif %}</span>
<span><strong>{{ item.title }}</strong><small>{{ item.hint }}</small></span>
</a>
{% endfor %}
</aside>
{% endif %}
{% endblock %}
<section class="setup-content">
{% if error %}<div class="alerts">{{ alert('danger', error) }}</div>{% endif %}
{% if message %}<div class="alerts">{{ alert('success', message) }}</div>{% endif %}
<article class="card setup-card">
{% block step %}{% endblock %}
</article>
</section>
</main>
<dialog class="dialog" id="confirm-dialog">
<form method="dialog">
<div class="dialog-body"><div class="dialog-icon">{{ icon('triangle-alert', 'icon-lg') }}</div><div><h3 data-confirm-title>Подтвердите действие</h3><p data-confirm-text></p></div></div>
<div class="dialog-actions"><button class="btn" value="cancel">Отмена</button><button class="btn btn-danger" type="button" data-confirm-ok>Продолжить</button></div>
</form>
</dialog>
<div id="toasts" aria-live="polite"></div>
<script src="/static/panel.js?v={{ app_version }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>
+48
Просмотреть файл
@@ -0,0 +1,48 @@
{% extends "setup_base.html" %}
{% from "_ui.html" import icon, alert %}
{% block rail %}{% endblock %}
{% block step %}
<div class="card-header"><div><h2>Перенастраиваем сеть</h2><p>Сервер объединяет сетевой порт в мост br0. Это займёт около 20 секунд, страница не перезагружается.</p></div></div>
<div class="card-body stack" data-bridge-wait data-confirm-url="{{ confirm_url }}" data-next-url="{{ next_url }}">
<div class="row" style="gap:14px"><span class="spinner" style="width:22px;height:22px;color:var(--brand)"></span><strong data-bridge-status>Ждём, пока сервер снова ответит…</strong></div>
<div class="progress indeterminate" data-bridge-progress><span></span></div>
<div class="alert info">{{ icon('info') }}<div><strong>Если страница так и не оживёт</strong>Ничего страшного: через 2 минуты сервер сам вернёт прежнюю сеть. Обновите страницу и выберите «Отдельная сеть (NAT)» или попробуйте ещё раз.</div></div>
</div>
<div class="card-footer">
<form method="post" action="{{ revert_url }}" style="margin-right:auto"><button class="btn btn-ghost">Отменить и вернуть прежнюю сеть</button></form>
<a class="btn btn-primary disabled" href="{{ next_url }}" data-bridge-next>Продолжить{{ icon('arrow-right') }}</a>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
var root = document.querySelector('[data-bridge-wait]');
var status = document.querySelector('[data-bridge-status]');
var next = document.querySelector('[data-bridge-next]');
var started = Date.now();
var tries = 0;
function done(ok, text) {
status.textContent = text;
document.querySelector('[data-bridge-progress]').classList.remove('indeterminate');
document.querySelector('[data-bridge-progress] > span').style.width = '100%';
document.querySelector('.spinner').style.display = 'none';
if (ok) { next.classList.remove('disabled'); setTimeout(function () { location.href = root.dataset.nextUrl; }, 1500); }
}
function attempt() {
tries += 1;
fetch('/healthz', { cache: 'no-store' }).then(function (r) {
if (!r.ok) throw new Error('not ready');
return fetch(root.dataset.confirmUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
}).then(function (r) { return r.json(); }).then(function (data) {
if (data.ok) done(true, 'Сеть работает и подтверждена. Машины будут в вашей домашней сети.');
else done(false, data.message || 'Не удалось подтвердить сеть');
}).catch(function () {
if (Date.now() - started > 150000) { done(false, 'Сервер не отвечает. Через 2 минуты он вернёт прежнюю сеть — обновите страницу.'); return; }
status.textContent = 'Ждём, пока сервер снова ответит… (' + Math.round((Date.now() - started) / 1000) + ' с)';
setTimeout(attempt, tries < 5 ? 3000 : 5000);
});
}
setTimeout(attempt, 4000);
})();
</script>
{% endblock %}