Мастер первичной настройки и 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
Этот коммит содержится в:
Подмодуль
+1
Submodule .claude/worktrees/wf_18d9ca1d-93c-1 added at 4198c18ff4
Подмодуль
+1
Submodule .claude/worktrees/wf_18d9ca1d-93c-2 added at 2450278d93
Подмодуль
+1
Submodule .claude/worktrees/wf_18d9ca1d-93c-3 added at 873b32c517
Подмодуль
+1
Submodule .claude/worktrees/wf_18d9ca1d-93c-4 added at 7bf7a0f6b7
@@ -35,6 +35,7 @@ Usage: virtuality-ctl <command> [options]
|
||||
bridge <iface> [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 ;;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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) => `<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);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
Ссылка в новой задаче
Block a user