diff --git a/scripts/virtuality-ctl b/scripts/virtuality-ctl index 1992f1d..fff8f4d 100755 --- a/scripts/virtuality-ctl +++ b/scripts/virtuality-ctl @@ -319,7 +319,7 @@ cmd_reconfigure() { cmd_set() { need_root set [[ $# -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 key="${pair%%=*}" value="${pair#*=}" @@ -331,8 +331,17 @@ cmd_set() { VIRTUALITY_WEB_HOST) [[ "$value" =~ ^[0-9a-fA-F.:]+$ ]] || die "адрес должен быть IP" ;; esac env_set "$key" "$value" + [[ "$key" == VIRTUALITY_AUTO_UPDATE || "$key" == VIRTUALITY_UPDATE_CHANNEL ]] || restart_needed=1 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 diff --git a/tests/dev_server.py b/tests/dev_server.py index 9dd38f2..94dc8d8 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -44,7 +44,7 @@ def seed(root: Path) -> None: 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. + # 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")] 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" @@ -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.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.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)): with (dirs["iso"] / name).open("wb") as handle: diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..792b372 --- /dev/null +++ b/tests/test_settings.py @@ -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 diff --git a/tests/test_setup.py b/tests/test_setup.py index 1ff7e03..99633b5 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -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): 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 response.status_code == 303 and response.headers["location"].startswith("/?message=") assert nodectl.wizard_done() diff --git a/web/app.py b/web/app.py index 5ad52da..bf4eda1 100644 --- a/web/app.py +++ b/web/app.py @@ -28,7 +28,7 @@ import auth import host_profile import network_core import presenters -from features import setup +from features import settings, setup import update_core from network_core import NetworkError @@ -2022,3 +2022,4 @@ def api_health(request: Request): app.include_router(setup.router) +app.include_router(settings.router) diff --git a/web/features/settings.py b/web/features/settings.py new file mode 100644 index 0000000..48eb5ca --- /dev/null +++ b/web/features/settings.py @@ -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) + + + diff --git a/web/features/setup.py b/web/features/setup.py index aa04be1..2fe1599 100644 --- a/web/features/setup.py +++ b/web/features/setup.py @@ -249,7 +249,7 @@ def setup_finish(request: Request): 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", "Сервер настроен. Можно создавать машины.") + return redirect_with_message("/", "message", "Сервер настроен. Можно создавать машины.") @router.get("/setup/skip") @@ -258,7 +258,7 @@ def setup_skip(request: Request): if auth: return auth nodectl.mark_wizard_done() - return redirect_with_message("/", "setup_message", "Мастер пропущен. Его можно запустить позже в Настройках.") + return redirect_with_message("/", "message", "Мастер пропущен. Его можно запустить позже в Настройках.") @router.post("/setup/restart") diff --git a/web/nodectl.py b/web/nodectl.py index 5a64bd1..4b06415 100644 --- a/web/nodectl.py +++ b/web/nodectl.py @@ -311,3 +311,8 @@ def power(action: str) -> tuple[bool, str]: 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"] + + +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"] diff --git a/web/templates/base.html b/web/templates/base.html index 5364d58..0579f81 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -45,6 +45,7 @@ {{ nav_link('/logs', 'scroll-text', 'Журналы', path.startswith('/logs')) }} {{ nav_link('/update', 'refresh-cw', 'Обновления', path.startswith('/update'), '' if notice.has_update else '') }} {{ nav_link('/host', 'server', 'Сервер', path.startswith('/host')) }} + {{ nav_link('/settings', 'settings', 'Настройки', path.startswith('/settings')) }} {{ nav_link('/help', 'circle-help', 'Помощь', path.startswith('/help')) }} @@ -69,7 +70,8 @@
{% block page_header %}{% endblock %} -
{% block alerts %}{% if error %}{{ alert('danger', error) }}{% endif %}{% endblock %}
+ {% set flash_error = error or request.query_params.get('error') %}{% set flash_message = message or request.query_params.get('message') %} +
{% block alerts %}{% if flash_error %}{{ alert('danger', flash_error) }}{% endif %}{% if flash_message %}{{ alert('success', flash_message) }}{% endif %}{% endblock %}
{% block content %}{% endblock %}
diff --git a/web/templates/settings.html b/web/templates/settings.html new file mode 100644 index 0000000..52fc193 --- /dev/null +++ b/web/templates/settings.html @@ -0,0 +1,174 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert %} +{% set page_title = 'Настройки' %} + +{% block page_header %} + +{% endblock %} + +{% block content %} +{% if not ctl %} + {{ alert('warning', 'Команда virtuality-ctl не найдена, поэтому изменения применить нельзя. Переустановите панель: sudo bash scripts/install_web_panel.sh', 'Панель установлена без virtuality-ctl') }} +{% endif %} +
+
+ + {# ------------------------------------------------ access #} +
+

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

Сейчас панель открывается по адресу {{ panel_url }}

{{ badge('HTTPS' if tls else 'HTTP', 'success' if tls else 'warning') }}
+
+
+
+ + +
+
+
Стандартный — 8088. С HTTPS панель отвечает на {{ tls_port }}, а этот порт перенаправляет на защищённый.
+
+
+ +
+
+ + {# ------------------------------------------------ password #} +
+

Пароль

Вход в панель — по пользователю {{ auth_user }} и его паролю Linux. Смена пароля здесь меняет его и для SSH.

+
+
+
+
+
+ +
+
+ + {# ------------------------------------------------ network #} +
+

Сеть машин

{% if net.on_bridge %}Машины подключены к вашей локальной сети через мост br0 — роутер выдаёт им адреса как обычным устройствам.{% else %}Машины живут в отдельной сети NAT: у них есть интернет, а доступ снаружи открывается в разделе «Сеть и доступ».{% endif %}

{{ badge('Локальная сеть' if net.on_bridge else 'NAT', 'success' if net.on_bridge else 'info') }}
+
+
+
Интерфейс{{ net.interface or '—' }}
+
Адрес сервера{{ net.address or '—' }}{% if net.prefix %}/{{ net.prefix }}{% endif %}
+
Шлюз{{ net.gateway or '—' }}
+
+ {% if net.revert_armed %} + {{ alert('warning', 'Мост включён, но ещё не подтверждён. Если не подтвердить, через 2 минуты сеть вернётся к прежней.', 'Ожидается подтверждение сети') }} + {% endif %} +
+ +
+ + {# ------------------------------------------------ storage #} +
+

Хранилище

Диски машин, образы и резервные копии лежат в {{ storage.path }}.

{{ badge('Отдельный диск' if storage.dedicated else 'Системный диск', 'success' if storage.dedicated else 'neutral') }}
+
+
+
Свободно{{ storage.free|format_bytes }} из {{ storage.total|format_bytes }}
+
Устройство{{ storage.source or '—' }}
+
+ {% if storage.spare %} +
+
+ {% for disk in storage.spare %} + + {% endfor %} +
+
+
+ {% else %} +

Свободных дисков не найдено. Подключите пустой диск от 16 ГБ — он появится здесь.

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

{{ 'Перезагружаем сервер' if action == 'reboot' else 'Выключаем сервер' }}

Машины выключаются корректно, это может занять до нескольких минут.

+
+
{{ message }}…
+
+ {% if action == 'reboot' %} +

Когда сервер снова включится, эта страница откроет панель сама. Обычно это занимает 1–2 минуты.

+ {% else %} +

Чтобы включить сервер снова, нажмите кнопку питания на нём. Эту вкладку можно закрыть.

+ {% endif %} +
+ +{% endblock %} +{% block scripts %} +{% if action == 'reboot' %} + +{% endif %} +{% endblock %}