Страница «Настройки» была добавлена: доступ, пароль, сеть машин, хранилище, время, обновления, сервер

- web/features/settings.py + settings.html: всё, что спрашивал мастер, меняется в одном месте
- перезагрузка/выключение с ожиданием возврата панели, архив настроек и отчёт для поддержки
- сообщения после редиректов показываются на любой странице (?message=, ?error=)
- virtuality-ctl set: настройки обновлений применяются без перезапуска панели
- tests/test_settings.py

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 14:55:31 +00:00
родитель 55f7d971f6
Коммит 2745a1280f
11 изменённых файлов: 497 добавлений и 8 удалений
+11 -2
Просмотреть файл
@@ -319,7 +319,7 @@ cmd_reconfigure() {
cmd_set() { cmd_set() {
need_root set need_root set
[[ $# -gt 0 ]] || die "usage: virtuality-ctl set KEY=VALUE [KEY=VALUE ...]" [[ $# -gt 0 ]] || die "usage: virtuality-ctl set KEY=VALUE [KEY=VALUE ...]"
local pair key value local pair key value restart_needed=0
for pair in "$@"; do for pair in "$@"; do
key="${pair%%=*}" key="${pair%%=*}"
value="${pair#*=}" value="${pair#*=}"
@@ -331,8 +331,17 @@ cmd_set() {
VIRTUALITY_WEB_HOST) [[ "$value" =~ ^[0-9a-fA-F.:]+$ ]] || die "адрес должен быть IP" ;; VIRTUALITY_WEB_HOST) [[ "$value" =~ ^[0-9a-fA-F.:]+$ ]] || die "адрес должен быть IP" ;;
esac esac
env_set "$key" "$value" env_set "$key" "$value"
[[ "$key" == VIRTUALITY_AUTO_UPDATE || "$key" == VIRTUALITY_UPDATE_CHANNEL ]] || restart_needed=1
done done
cmd_reconfigure if [[ "$restart_needed" == "1" ]]; then
cmd_reconfigure
else
# Update settings only touch the timer: no need to restart the panel.
need_root set
write_units
log "settings updated without restart: $*"
echo "Настройки сохранены"
fi
} }
# ---------------------------------------------------------------- account # ---------------------------------------------------------------- account
+6 -1
Просмотреть файл
@@ -44,7 +44,7 @@ def seed(root: Path) -> None:
nodectl.WIZARD_FILE = dirs["config"] / "wizard.json" nodectl.WIZARD_FILE = dirs["config"] / "wizard.json"
stage = os.environ.get("VIRTUALITY_DEV_SETUP", "") stage = os.environ.get("VIRTUALITY_DEV_SETUP", "")
if stage: if stage:
# VIRTUALITY_DEV_SETUP=installing|done shows the setup wizard in the demo. # VIRTUALITY_DEV_SETUP=installing|done shows the setup wizard in the demo; finished = wizard completed (Settings page).
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")] 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.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 = dirs["config"] / "firstboot.log"
@@ -54,6 +54,11 @@ def seed(root: Path) -> None:
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.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.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) nodectl.set_timezone = lambda tz: (True, tz)
nodectl.timezones = lambda: {"current": "Europe/Moscow", "popular": nodectl.POPULAR_TIMEZONES, "all": nodectl.POPULAR_TIMEZONES}
if stage == "finished":
nodectl.mark_wizard_done()
nodectl.NODE_ENV = dirs["config"] / "web.env"
nodectl.NODE_ENV.write_text("VIRTUALITY_WEB_PORT=8088\nVIRTUALITY_TLS=1\nVIRTUALITY_AUTO_UPDATE=1\nVIRTUALITY_UPDATE_CHANNEL=stable\n")
for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)): 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: with (dirs["iso"] / name).open("wb") as handle:
+88
Просмотреть файл
@@ -0,0 +1,88 @@
import nodectl
from test_setup import prepare
def ready(data_dirs, monkeypatch, ctl=True):
prepare(data_dirs, monkeypatch)
nodectl.mark_wizard_done()
monkeypatch.setattr(nodectl, "ctl_available", lambda: ctl)
nodectl.NODE_ENV = data_dirs["config"] / "web.env"
nodectl.NODE_ENV.write_text("VIRTUALITY_WEB_PORT=8088\nVIRTUALITY_TLS=0\nVIRTUALITY_AUTO_UPDATE=1\nVIRTUALITY_UPDATE_CHANNEL=stable\n")
def test_settings_page_renders(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
html = logged_in.get("/settings").text
for text in ("Доступ к панели", "Сеть машин", "Хранилище", "Часовой пояс", "Обновления", "Перезагрузить", "http://panel.test:8088"):
assert text in html, text
def test_settings_page_without_ctl_disables_actions(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch, ctl=False)
html = logged_in.get("/settings").text
assert "virtuality-ctl не найдена" in html and "disabled" in html
def test_settings_requires_login(client, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
assert client.get("/settings", follow_redirects=False).status_code == 303
def test_access_change_applies_and_shows_new_url(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
calls = []
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
response = logged_in.post("/settings/access", data={"tls": "1", "port": "8088"})
assert response.status_code == 200 and "https://panel.test:8443" in response.text
assert calls == [("set", "VIRTUALITY_TLS=1", "VIRTUALITY_WEB_PORT=8088")]
assert logged_in.post("/settings/access", data={"tls": "0", "port": "22"}).status_code == 400
unchanged = logged_in.post("/settings/access", data={"tls": "0", "port": "8088"}, follow_redirects=False)
assert unchanged.status_code == 303 and "message=" in unchanged.headers["location"]
def test_password_change(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
seen = {}
monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: seen.update(args=args, stdin=kw.get("stdin")) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
assert logged_in.post("/settings/password", data={"password": "abc", "password2": "abd"}).status_code == 400
assert logged_in.post("/settings/password", data={"password": "abc", "password2": "abc"}).status_code == 400
response = logged_in.post("/settings/password", data={"password": "secret1", "password2": "secret1"}, follow_redirects=False)
assert response.status_code == 303 and seen["args"] == ("passwd", "tester") and seen["stdin"] == "secret1\n"
def test_timezone_and_updates(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
calls = []
monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
assert logged_in.post("/settings/timezone", data={"timezone": "Europe/Moscow"}, follow_redirects=False).status_code == 303
assert logged_in.post("/settings/timezone", data={"timezone": "../etc"}).status_code == 400
assert logged_in.post("/settings/updates", data={"channel": "main"}, follow_redirects=False).status_code == 303
assert calls == [("timezone", "Europe/Moscow"), ("set", "VIRTUALITY_AUTO_UPDATE=0", "VIRTUALITY_UPDATE_CHANNEL=main")]
def test_network_bridge_and_revert(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
calls = []
monkeypatch.setattr(nodectl, "enable_bridge", lambda iface, mode="dhcp": calls.append(("bridge", iface, mode)) or (True, "ok"))
monkeypatch.setattr(nodectl, "revert_bridge", lambda: calls.append(("revert",)) or (True, "Возвращаем прежнюю сеть"))
response = logged_in.post("/settings/network", data={"mode": "bridge"})
assert response.status_code == 200 and "Перенастраиваем сеть" in response.text and 'data-next-url="/settings"' in response.text
assert logged_in.post("/settings/network/revert", follow_redirects=False).headers["location"].startswith("/settings?message=")
assert calls == [("bridge", "enp3s0", "dhcp"), ("revert",)]
def test_storage_power_and_downloads(logged_in, data_dirs, monkeypatch, tmp_path):
ready(data_dirs, monkeypatch)
monkeypatch.setattr(nodectl, "use_disk_for_storage", lambda path: (path == "/dev/sdb", "Диск подключён" if path == "/dev/sdb" else "Нельзя"))
assert "message=" in logged_in.post("/settings/storage", data={"disk": "/dev/sdb"}, follow_redirects=False).headers["location"]
assert "error=" in logged_in.post("/settings/storage", data={"disk": "/dev/sda"}, follow_redirects=False).headers["location"]
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
assert "Перезагружаем сервер" in logged_in.post("/settings/power", data={"action": "reboot"}).text
assert logged_in.post("/settings/power", data={"action": "halt"}).status_code == 400
archive = tmp_path / "config-1.tar.gz"
archive.write_bytes(b"\x1f\x8b" + b"0" * 16)
monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: {"ok": True, "stdout": str(archive), "stderr": "", "code": 0, "cmd": ""})
response = logged_in.post("/settings/backup-config")
assert response.status_code == 200 and response.headers["content-disposition"].endswith('"config-1.tar.gz"') and response.content.startswith(b"\x1f\x8b")
assert logged_in.post("/settings/support-bundle").status_code == 200
+1 -1
Просмотреть файл
@@ -67,7 +67,7 @@ def test_wizard_steps_render_and_store_choices(logged_in, data_dirs, monkeypatch
def test_finish_marks_done_without_ctl(logged_in, data_dirs, monkeypatch): def test_finish_marks_done_without_ctl(logged_in, data_dirs, monkeypatch):
prepare(data_dirs, monkeypatch) prepare(data_dirs, monkeypatch)
response = logged_in.post("/setup/finish", follow_redirects=False) response = logged_in.post("/setup/finish", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"].startswith("/?setup_message=") assert response.status_code == 303 and response.headers["location"].startswith("/?message=")
assert nodectl.wizard_done() assert nodectl.wizard_done()
+2 -1
Просмотреть файл
@@ -28,7 +28,7 @@ import auth
import host_profile import host_profile
import network_core import network_core
import presenters import presenters
from features import setup from features import settings, setup
import update_core import update_core
from network_core import NetworkError from network_core import NetworkError
@@ -2022,3 +2022,4 @@ def api_health(request: Request):
app.include_router(setup.router) app.include_router(setup.router)
app.include_router(settings.router)
+166
Просмотреть файл
@@ -0,0 +1,166 @@
"""Settings page: everything the setup wizard configures, changeable later in one place."""
import re
from pathlib import Path
from typing import Any
import network_core
import nodectl
from fastapi import APIRouter, Form, Request
from fastapi.responses import FileResponse
from core import APP_VERSION, AUTH_USER, redirect_with_message, render, require_auth
router = APIRouter()
def page(request: Request, error: str | None = None, message: str | None = None, status_code: int = 200):
settings = nodectl.node_settings()
host = request.url.hostname or "127.0.0.1"
context: dict[str, Any] = {
"page_title": "Настройки", "error": error, "message": message, "settings": settings, "auth_user": AUTH_USER,
"tls": settings.get("VIRTUALITY_TLS") == "1", "port": settings.get("VIRTUALITY_WEB_PORT", "8088"), "tls_port": settings.get("VIRTUALITY_TLS_PORT", "8443"),
"auto_update": settings.get("VIRTUALITY_AUTO_UPDATE", "1") == "1", "channel": settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable"),
"timezones": nodectl.timezones(), "net": nodectl.network_facts(), "storage": nodectl.storage_facts(),
"hardware": nodectl.hardware_summary(), "ctl": nodectl.ctl_available(), "version": APP_VERSION,
"panel_url": nodectl.panel_url(host, settings), "source_dir": settings.get("VIRTUALITY_SOURCE_DIR", "/opt/virtuality/source"),
}
return render(request, "settings.html", context, status_code=status_code)
@router.get("/settings")
def settings_page(request: Request, message: str = "", error: str = ""):
auth = require_auth(request)
if auth:
return auth
return page(request, error=error or None, message=message or None)
@router.post("/settings/access")
def settings_access(request: Request, tls: str = Form("0"), port: str = Form("8088")):
auth = require_auth(request)
if auth:
return auth
if not re.fullmatch(r"\d{1,5}", port) or not 1 <= int(port) <= 65535 or int(port) in (22, 9090):
return page(request, error="Порт должен быть числом от 1 до 65535 (кроме 22 и 9090)", status_code=400)
settings = nodectl.node_settings()
tls = "1" if tls == "1" else "0"
if tls == settings.get("VIRTUALITY_TLS", "0") and port == settings.get("VIRTUALITY_WEB_PORT", "8088"):
return redirect_with_message("/settings", "message", "Доступ уже настроен так")
ok, detail = nodectl.apply_settings({"VIRTUALITY_TLS": tls, "VIRTUALITY_WEB_PORT": port})
if not ok:
return page(request, error=detail, status_code=500)
host = request.url.hostname or "127.0.0.1"
final_url = f"https://{host}:{settings.get('VIRTUALITY_TLS_PORT', '8443')}" if tls == "1" else f"http://{host}:{port}"
return render(request, "setup_applying.html", {"page_title": "Применяем настройки", "final_url": final_url, "tls": tls == "1"})
@router.post("/settings/password")
def settings_password(request: Request, password: str = Form(""), password2: str = Form("")):
auth = require_auth(request)
if auth:
return auth
if password != password2:
return page(request, error="Пароли не совпадают", status_code=400)
ok, detail = nodectl.change_password(AUTH_USER, password)
if not ok:
return page(request, error=detail, status_code=400)
return redirect_with_message("/settings", "message", detail)
@router.post("/settings/timezone")
def settings_timezone(request: Request, timezone: str = Form("")):
auth = require_auth(request)
if auth:
return auth
ok, detail = nodectl.set_timezone(timezone)
if not ok:
return page(request, error=detail, status_code=400)
return redirect_with_message("/settings", "message", f"Часовой пояс: {detail}")
@router.post("/settings/updates")
def settings_updates(request: Request, auto_update: str = Form("0"), channel: str = Form("stable")):
auth = require_auth(request)
if auth:
return auth
if channel not in ("stable", "main"):
channel = "stable"
ok, detail = nodectl.apply_settings({"VIRTUALITY_AUTO_UPDATE": "1" if auto_update == "1" else "0", "VIRTUALITY_UPDATE_CHANNEL": channel})
return redirect_with_message("/settings", "message" if ok else "error", "Настройки обновлений сохранены" if ok else detail)
@router.post("/settings/network")
def settings_network(request: Request, mode: str = Form("nat")):
auth = require_auth(request)
if auth:
return auth
facts = nodectl.network_facts()
if mode == "nat":
try:
network_core.create_nat_network()
except network_core.NetworkError as exc:
return page(request, error=f"Не удалось создать сеть машин: {exc}", status_code=500)
return redirect_with_message("/settings", "message", "Сеть NAT готова. Новые машины будут подключаться к ней.")
if mode != "bridge":
return page(request, error="Выберите режим сети", status_code=400)
if facts["on_bridge"]:
return redirect_with_message("/settings", "message", "Сервер уже подключён через мост br0")
if not facts["bridge_possible"]:
return page(request, error="Мост недоступен: Wi-Fi или не найден проводной интерфейс", status_code=400)
ok, detail = nodectl.enable_bridge(facts["interface"], "dhcp")
if not ok:
return page(request, error=detail, status_code=500)
return render(request, "setup_bridge_wait.html", {"page_title": "Перенастройка сети", "next_url": "/settings", "confirm_url": "/setup/network/confirm", "revert_url": "/settings/network/revert", "back_url": "/settings"})
@router.post("/settings/network/revert")
def settings_network_revert(request: Request):
auth = require_auth(request)
if auth:
return auth
ok, detail = nodectl.revert_bridge()
return redirect_with_message("/settings", "message" if ok else "error", detail)
@router.post("/settings/storage")
def settings_storage(request: Request, disk: str = Form("")):
auth = require_auth(request)
if auth:
return auth
ok, detail = nodectl.use_disk_for_storage(disk)
return redirect_with_message("/settings", "message" if ok else "error", detail)
@router.post("/settings/power")
def settings_power(request: Request, action: str = Form("")):
auth = require_auth(request)
if auth:
return auth
ok, detail = nodectl.power(action)
if not ok:
return page(request, error=detail, status_code=400)
return render(request, "settings_power.html", {"page_title": "Сервер", "action": action, "message": detail})
@router.post("/settings/backup-config")
def settings_backup_config(request: Request):
auth = require_auth(request)
if auth:
return auth
ok, detail = nodectl.backup_config()
if not ok or not Path(detail).is_file():
return page(request, error=detail if not ok else "Архив не найден", status_code=500)
return FileResponse(detail, media_type="application/gzip", filename=Path(detail).name)
@router.post("/settings/support-bundle")
def settings_support_bundle(request: Request):
auth = require_auth(request)
if auth:
return auth
ok, detail = nodectl.support_bundle()
if not ok or not Path(detail).is_file():
return page(request, error=detail if not ok else "Отчёт не найден", status_code=500)
return FileResponse(detail, media_type="application/gzip", filename=Path(detail).name)
+2 -2
Просмотреть файл
@@ -249,7 +249,7 @@ def setup_finish(request: Request):
return render_step(request, "finish", error=detail, status_code=500) 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']}" 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 render(request, "setup_applying.html", {"page_title": "Применяем настройки", "final_url": final_url, "tls": changes["VIRTUALITY_TLS"] == "1"})
return redirect_with_message("/", "setup_message", "Сервер настроен. Можно создавать машины.") return redirect_with_message("/", "message", "Сервер настроен. Можно создавать машины.")
@router.get("/setup/skip") @router.get("/setup/skip")
@@ -258,7 +258,7 @@ def setup_skip(request: Request):
if auth: if auth:
return auth return auth
nodectl.mark_wizard_done() nodectl.mark_wizard_done()
return redirect_with_message("/", "setup_message", "Мастер пропущен. Его можно запустить позже в Настройках.") return redirect_with_message("/", "message", "Мастер пропущен. Его можно запустить позже в Настройках.")
@router.post("/setup/restart") @router.post("/setup/restart")
+5
Просмотреть файл
@@ -311,3 +311,8 @@ def power(action: str) -> tuple[bool, str]:
def backup_config() -> tuple[bool, str]: def backup_config() -> tuple[bool, str]:
result = ctl("backup-config", timeout=120) result = ctl("backup-config", timeout=120)
return result["ok"], cmd_error(result, "Не удалось создать архив настроек") if not result["ok"] else result["stdout"] return result["ok"], cmd_error(result, "Не удалось создать архив настроек") if not result["ok"] else result["stdout"]
def support_bundle() -> tuple[bool, str]:
result = ctl("support-bundle", timeout=120)
return result["ok"], cmd_error(result, "Не удалось собрать отчёт") if not result["ok"] else result["stdout"]
+3 -1
Просмотреть файл
@@ -45,6 +45,7 @@
{{ nav_link('/logs', 'scroll-text', 'Журналы', path.startswith('/logs')) }} {{ nav_link('/logs', 'scroll-text', 'Журналы', path.startswith('/logs')) }}
{{ nav_link('/update', 'refresh-cw', 'Обновления', path.startswith('/update'), '<span class="nav-dot" title="Доступно обновление"></span>' if notice.has_update else '') }} {{ nav_link('/update', 'refresh-cw', 'Обновления', path.startswith('/update'), '<span class="nav-dot" title="Доступно обновление"></span>' if notice.has_update else '') }}
{{ nav_link('/host', 'server', 'Сервер', path.startswith('/host')) }} {{ nav_link('/host', 'server', 'Сервер', path.startswith('/host')) }}
{{ nav_link('/settings', 'settings', 'Настройки', path.startswith('/settings')) }}
{{ nav_link('/help', 'circle-help', 'Помощь', path.startswith('/help')) }} {{ nav_link('/help', 'circle-help', 'Помощь', path.startswith('/help')) }}
</nav> </nav>
@@ -69,7 +70,8 @@
<main class="content"> <main class="content">
{% block page_header %}{% endblock %} {% block page_header %}{% endblock %}
<div class="alerts">{% block alerts %}{% if error %}{{ alert('danger', error) }}{% endif %}{% endblock %}</div> {% set flash_error = error or request.query_params.get('error') %}{% set flash_message = message or request.query_params.get('message') %}
<div class="alerts">{% block alerts %}{% if flash_error %}{{ alert('danger', flash_error) }}{% endif %}{% if flash_message %}{{ alert('success', flash_message) }}{% endif %}{% endblock %}</div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</div> </div>
+174
Просмотреть файл
@@ -0,0 +1,174 @@
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, alert %}
{% set page_title = 'Настройки' %}
{% block page_header %}
<div class="page-header">
<div>
<h1>Настройки</h1>
<p class="lead">Доступ к панели, сеть машин, хранилище и обновления. Всё то же, что спрашивал мастер настройки, — можно поменять в любой момент.</p>
</div>
<div class="page-actions">
<form method="post" action="/setup/restart" data-confirm="Мастер проведёт вас по всем шагам заново. Текущие машины и настройки не пострадают." data-confirm-title="Запустить мастер настройки?" data-confirm-button="Запустить" data-confirm-tone="brand"><button class="btn">{{ icon('sparkles', 'icon-sm') }}Мастер настройки</button></form>
</div>
</div>
{% endblock %}
{% block content %}
{% if not ctl %}
{{ alert('warning', 'Команда virtuality-ctl не найдена, поэтому изменения применить нельзя. Переустановите панель: sudo bash scripts/install_web_panel.sh', 'Панель установлена без virtuality-ctl') }}
{% endif %}
<section class="grid grid-main" style="margin-top:{{ '16px' if not ctl else '0' }}">
<div class="stack">
{# ------------------------------------------------ access #}
<article class="card" id="access">
<div class="card-header"><div><h2>Доступ к панели</h2><p>Сейчас панель открывается по адресу <a class="mono" href="{{ panel_url }}">{{ panel_url }}</a></p></div>{{ badge('HTTPS' if tls else 'HTTP', 'success' if tls else 'warning') }}</div>
<form method="post" action="/settings/access" data-confirm="Панель перезапустится и откроется по новому адресу. Машины продолжат работать." data-confirm-title="Применить настройки доступа?" data-confirm-button="Применить" data-confirm-tone="brand">
<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>
<div class="form-grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">
<div class="field"><label for="port">Порт HTTP</label><input class="input" id="port" type="number" name="port" min="1" max="65535" value="{{ port }}"><span class="hint">Стандартный — 8088. С HTTPS панель отвечает на {{ tls_port }}, а этот порт перенаправляет на защищённый.</span></div>
</div>
</div>
<div class="card-footer"><button class="btn btn-primary" {% if not ctl %}disabled{% endif %}>Применить</button></div>
</form>
</article>
{# ------------------------------------------------ password #}
<article class="card" id="password">
<div class="card-header"><div><h2>Пароль</h2><p>Вход в панель — по пользователю <strong>{{ auth_user }}</strong> и его паролю Linux. Смена пароля здесь меняет его и для SSH.</p></div></div>
<form method="post" action="/settings/password">
<div class="card-body form-grid" style="grid-template-columns:repeat(auto-fit,minmax(220px,1fr))">
<div class="field"><label for="password">Новый пароль</label><input class="input" id="password" type="password" name="password" minlength="6" autocomplete="new-password" required></div>
<div class="field"><label for="password2">Ещё раз</label><input class="input" id="password2" type="password" name="password2" minlength="6" autocomplete="new-password" required></div>
</div>
<div class="card-footer"><button class="btn" {% if not ctl %}disabled{% endif %}>{{ icon('lock', 'icon-sm') }}Сменить пароль</button></div>
</form>
</article>
{# ------------------------------------------------ network #}
<article class="card" id="network">
<div class="card-header"><div><h2>Сеть машин</h2><p>{% if net.on_bridge %}Машины подключены к вашей локальной сети через мост br0 — роутер выдаёт им адреса как обычным устройствам.{% else %}Машины живут в отдельной сети NAT: у них есть интернет, а доступ снаружи открывается в разделе «Сеть и доступ».{% endif %}</p></div>{{ badge('Локальная сеть' if net.on_bridge else 'NAT', 'success' if net.on_bridge else 'info') }}</div>
<div class="card-body stack">
<div class="kv">
<div><span>Интерфейс</span><strong class="mono">{{ net.interface or '—' }}</strong></div>
<div><span>Адрес сервера</span><strong class="mono">{{ net.address or '—' }}{% if net.prefix %}/{{ net.prefix }}{% endif %}</strong></div>
<div><span>Шлюз</span><strong class="mono">{{ net.gateway or '—' }}</strong></div>
</div>
{% if net.revert_armed %}
{{ alert('warning', 'Мост включён, но ещё не подтверждён. Если не подтвердить, через 2 минуты сеть вернётся к прежней.', 'Ожидается подтверждение сети') }}
{% endif %}
</div>
<div class="card-footer" style="gap:8px;flex-wrap:wrap">
{% if net.on_bridge %}
<form method="post" action="/settings/network/revert" data-confirm="Сервер вернётся к прежней сети, машины перестанут быть видны в локальной сети. Их можно будет подключить к сети NAT." data-confirm-title="Вернуть прежнюю сеть?" data-confirm-button="Вернуть"><button class="btn" {% if not ctl %}disabled{% endif %}>{{ icon('rotate-cw', 'icon-sm') }}Вернуть прежнюю сеть</button></form>
{% else %}
<form method="post" action="/settings/network"><input type="hidden" name="mode" value="nat"><button class="btn">{{ icon('refresh-cw', 'icon-sm') }}Проверить сеть NAT</button></form>
<form method="post" action="/settings/network" data-confirm="Сетевой порт {{ net.interface }} будет объединён в мост br0, панель на ~20 секунд перестанет отвечать. Если что-то пойдёт не так, сервер сам вернёт прежнюю сеть через 2 минуты." data-confirm-title="Подключить машины к локальной сети?" data-confirm-button="Подключить" data-confirm-tone="brand"><input type="hidden" name="mode" value="bridge"><button class="btn btn-primary" {% if not (ctl and net.bridge_possible) %}disabled title="{{ 'Wi-Fi или не найден проводной интерфейс' if not net.bridge_possible else '' }}"{% endif %}>{{ icon('network', 'icon-sm') }}Подключить к локальной сети</button></form>
{% endif %}
</div>
</article>
{# ------------------------------------------------ storage #}
<article class="card" id="storage">
<div class="card-header"><div><h2>Хранилище</h2><p>Диски машин, образы и резервные копии лежат в <span class="mono">{{ storage.path }}</span>.</p></div>{{ badge('Отдельный диск' if storage.dedicated else 'Системный диск', 'success' if storage.dedicated else 'neutral') }}</div>
<div class="card-body stack">
<div class="kv">
<div><span>Свободно</span><strong>{{ storage.free|format_bytes }} из {{ storage.total|format_bytes }}</strong></div>
<div><span>Устройство</span><strong class="mono">{{ storage.source or '—' }}</strong></div>
</div>
{% if storage.spare %}
<form method="post" action="/settings/storage" class="stack" data-confirm="Выбранный диск будет отформатирован — все данные на нём пропадут. Текущие машины и образы переедут на него автоматически." data-confirm-title="Отдать диск под машины?" data-confirm-button="Форматировать и подключить">
<div class="options">
{% for disk in storage.spare %}
<label class="option">
<input type="radio" name="disk" value="{{ disk.path }}" {% if loop.first %}checked{% endif %}>
<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>
<div><button class="btn" {% if not ctl %}disabled{% endif %} data-busy="Готовим диск…">{{ icon('hard-drive', 'icon-sm') }}Использовать этот диск для машин</button></div>
</form>
{% else %}
<p class="muted">Свободных дисков не найдено. Подключите пустой диск от 16 ГБ — он появится здесь.</p>
{% endif %}
</div>
</article>
</div>
<aside class="stack">
{# ------------------------------------------------ time #}
<article class="card" id="time">
<div class="card-header"><div><h2>Время</h2><p>Часовой пояс сервера — по нему показываются журналы и задачи.</p></div></div>
<form method="post" action="/settings/timezone">
<div class="card-body 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 class="card-footer"><button class="btn" {% if not ctl %}disabled{% endif %}>Сохранить</button></div>
</form>
</article>
{# ------------------------------------------------ updates #}
<article class="card" id="updates">
<div class="card-header"><div><h2>Обновления</h2><p>Машины во время обновления продолжают работать.</p></div>{{ badge('Авто' if auto_update else 'Вручную', 'success' if auto_update else 'neutral') }}</div>
<form method="post" action="/settings/updates">
<div class="card-body stack">
<label class="switch-row" style="cursor:pointer">
<span class="switch-text"><strong>Обновлять автоматически</strong><span>Ночью, около 4 часов по времени сервера</span></span>
<input type="checkbox" name="auto_update" value="1" {% if auto_update %}checked{% endif %} style="width:20px;height:20px;accent-color:var(--brand)">
</label>
<div class="field">
<label for="channel">Канал</label>
<select class="select" id="channel" name="channel">
<option value="stable" {% if channel == 'stable' %}selected{% endif %}>Стабильный — выпущенные версии</option>
<option value="main" {% if channel == 'main' %}selected{% endif %}>Ранний доступ — все изменения</option>
</select>
</div>
</div>
<div class="card-footer"><a class="btn btn-ghost btn-sm" href="/update" style="margin-right:auto">Проверить обновления</a><button class="btn" {% if not ctl %}disabled{% endif %}>Сохранить</button></div>
</form>
</article>
{# ------------------------------------------------ server #}
<article class="card" id="server">
<div class="card-header"><div><h2>Сервер</h2><p>{{ hardware.hostname }} · Virtuality {{ version }}</p></div></div>
<div class="card-body stack-sm">
<form method="post" action="/settings/backup-config"><button class="btn" style="width:100%;justify-content:flex-start" {% if not ctl %}disabled{% endif %}>{{ icon('download', 'icon-sm') }}Скачать архив настроек</button></form>
<form method="post" action="/settings/support-bundle"><button class="btn" style="width:100%;justify-content:flex-start" {% if not ctl %}disabled{% endif %}>{{ icon('package', 'icon-sm') }}Отчёт для поддержки</button></form>
<p class="subtle small">Архив — настройки ноды и описания машин и сети, чтобы восстановить их на новом сервере. Отчёт — журналы и состояние для помощи при проблемах.</p>
<div class="row" style="gap:8px;margin-top:6px">
<form method="post" action="/settings/power" class="grow" data-confirm="Машины будут корректно выключены, сервер перезагрузится и снова запустит их. Панель будет недоступна пару минут." data-confirm-title="Перезагрузить сервер?" data-confirm-button="Перезагрузить"><input type="hidden" name="action" value="reboot"><button class="btn" style="width:100%" {% if not ctl %}disabled{% endif %}>{{ icon('rotate-cw', 'icon-sm') }}Перезагрузить</button></form>
<form method="post" action="/settings/power" class="grow" data-confirm="Машины будут корректно выключены, сервер выключится. Включить его можно будет только кнопкой питания." data-confirm-title="Выключить сервер?" data-confirm-button="Выключить"><input type="hidden" name="action" value="poweroff"><button class="btn btn-danger" style="width:100%" {% if not ctl %}disabled{% endif %}>{{ icon('power', 'icon-sm') }}Выключить</button></form>
</div>
</div>
</article>
<details class="advanced">
<summary>{{ icon('terminal', 'icon-sm') }}Для терминала<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body stack-sm">
<p class="subtle small">Всё то же самое доступно командой <code>sudo virtuality-ctl</code>: <code>status</code>, <code>set VIRTUALITY_TLS=1</code>, <code>passwd</code>, <code>timezone</code>, <code>bridge</code>, <code>storage-use</code>, <code>backup-config</code>, <code>uninstall</code>.</p>
<p class="subtle small">Исходники: <span class="mono">{{ source_dir }}</span></p>
</div>
</details>
</aside>
</section>
{% endblock %}
+39
Просмотреть файл
@@ -0,0 +1,39 @@
{% extends "setup_base.html" %}
{% from "_ui.html" import icon %}
{% block rail %}{% endblock %}
{% block step %}
<div class="card-header"><div><h2>{{ 'Перезагружаем сервер' if action == 'reboot' else 'Выключаем сервер' }}</h2><p>Машины выключаются корректно, это может занять до нескольких минут.</p></div></div>
<div class="card-body stack" data-power="{{ action }}">
<div class="row" style="gap:14px"><span class="spinner" style="width:22px;height:22px;color:var(--brand)"></span><strong data-power-status>{{ message }}…</strong></div>
<div class="progress indeterminate"><span></span></div>
{% if action == 'reboot' %}
<p class="subtle small">Когда сервер снова включится, эта страница откроет панель сама. Обычно это занимает 1–2 минуты.</p>
{% else %}
<p class="subtle small">Чтобы включить сервер снова, нажмите кнопку питания на нём. Эту вкладку можно закрыть.</p>
{% endif %}
</div>
<div class="card-footer"><a class="btn btn-ghost" href="/">На главную</a></div>
{% endblock %}
{% block scripts %}
{% if action == 'reboot' %}
<script>
(function () {
var status = document.querySelector('[data-power-status]');
var down = false, started = Date.now();
function poll() {
fetch('/healthz', { cache: 'no-store' }).then(function (r) {
if (!r.ok) throw new Error('down');
if (down) { status.textContent = 'Сервер снова работает — открываем панель'; setTimeout(function () { location.href = '/'; }, 1000); return; }
status.textContent = 'Выключаем машины и перезагружаемся… (' + Math.round((Date.now() - started) / 1000) + ' с)';
setTimeout(poll, 3000);
}).catch(function () {
down = true;
status.textContent = 'Сервер перезагружается, ждём… (' + Math.round((Date.now() - started) / 1000) + ' с)';
setTimeout(poll, 4000);
});
}
setTimeout(poll, 5000);
})();
</script>
{% endif %}
{% endblock %}