14 Коммитов
Автор SHA1 Сообщение Дата
viktor a840e0c828 Версия была обновлена до 0.12.8
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 22:42:23 +09:00
viktor c19d8db6e7 Выбор образа при создании машины карточками: название системы, размер, дата загрузки и поиск, когда образов много 2026-09-27 22:42:23 +09:00
viktor c04e5285e7 Версия была обновлена до 0.12.7
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 21:24:19 +09:00
viktor 5d02cc81ff Установщик Windows узнаётся по метке ISO и имени файла: форма создания машины сама выбирает тип Windows, иначе диск был virtio и установщик его не видел 2026-09-27 21:24:19 +09:00
viktor ff3b09aba4 Версия была обновлена до 0.12.6
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 21:08:17 +09:00
viktor 2bf4ef35c2 Удаление машины больше не стирает вставленный установочный ISO: вместо --remove-all-storage удаляются только диски машины из папки машин 2026-09-27 21:08:17 +09:00
viktor ca5d49266c Версия была обновлена до 0.12.5
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:58:07 +09:00
viktor ec9428c2eb Удалить машину можно в любом состоянии: работающая выключается, пока идёт задача удаление ждёт, пробросы портов машины удаляются вместе с ней 2026-09-27 17:58:07 +09:00
viktor f31b4ade1d Версия была обновлена до 0.12.4
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:45:03 +09:00
viktor 2474ad3dc2 Машина с ISO после установки перезагружается сама: создаётся без отдельной фазы установки (no_install), диск первым в порядке загрузки 2026-09-27 17:45:03 +09:00
viktor daeec06ec6 Версия была обновлена до 0.12.3
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:32:45 +09:00
viktor 209e83044a Экран машины снова подключается: noVNC 1.6 строил адрес WebSocket от своей страницы и уходил в /novnc/console/ws, путь теперь работает и с 1.6, и с 1.3 2026-09-27 17:32:45 +09:00
viktor 5d4525f78d Версия была обновлена до 0.12.2
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:24:01 +09:00
viktor 984eb85a5a Неудачное создание машины: панель больше не предлагает экран несуществующей машины, даёт «Создать заново» с тем же образом и удаляет недосозданный диск 2026-09-27 17:24:01 +09:00
24 изменённых файлов: 557 добавлений и 83 удалений
+4 -4
Просмотреть файл
@@ -2,7 +2,7 @@
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка. **Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
Текущая версия: **0.12.1** (файл `VERSION`, журнал изменений — `updates/versions.json`). Текущая версия: **0.12.8** (файл `VERSION`, журнал изменений — `updates/versions.json`).
Что умеет: Что умеет:
@@ -76,7 +76,7 @@
sudo apt install -y xorriso git curl python3-pip sudo apt install -y xorriso git curl python3-pip
git clone https://git.stackworks.ru/viktor/virtuality.git git clone https://git.stackworks.ru/viktor/virtuality.git
cd virtuality cd virtuality
make iso # dist/virtuality-0.12.1-ubuntu-26.04-amd64.iso make iso # dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso
``` ```
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md). Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
@@ -166,9 +166,9 @@ sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панел
**Машины** **Машины**
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть. - Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны. - Тип системы для ISO (установщик Windows узнаётся по образу и выбирается сам): Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно. - Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками. - Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается, установочные ISO остаются).
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места). - Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ). - Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти. - **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.12.1 0.12.8
+1 -1
Просмотреть файл
@@ -60,7 +60,7 @@
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова. 1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком. 2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
3. После установки на странице машины откройте вкладку **Диски и загрузка** → **Привод CD/DVD** и извлеките установочный образ, чтобы машина больше не предлагала загрузку с него. 3. После установки машина перезагрузится уже с диска: для машин из ISO панель ставит диск первым в порядке загрузки, пока он пустой, машина загружается с образа. Сам образ можно извлечь во вкладке **Диски и загрузка** → **Привод CD/DVD**.
Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**. Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**.
+7 -7
Просмотреть файл
@@ -44,8 +44,8 @@ make iso ARCH=arm64 # ARM64-серверы с UEFI
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`: `image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
```text ```text
dist/virtuality-0.12.1-ubuntu-26.04-amd64.iso dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso
dist/virtuality-0.12.1-ubuntu-26.04-amd64.iso.sha256 dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso.sha256
``` ```
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно: Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
@@ -109,7 +109,7 @@ make iso ISO_ARGS="--unattended --username sysadmin --password-hash '$(openssl p
## Что спрашивает установщик ## Что спрашивает установщик
Меню загрузки: **Установить Virtuality 0.12.1** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти. Меню загрузки: **Установить Virtuality 0.12.8** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu: Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
@@ -161,14 +161,14 @@ cat /var/log/virtuality/firstboot.log
Сверьте контрольную сумму скачанного или собранного образа: Сверьте контрольную сумму скачанного или собранного образа:
```bash ```bash
sha256sum -c virtuality-0.12.1-ubuntu-26.04-amd64.iso.sha256 sha256sum -c virtuality-0.12.8-ubuntu-26.04-amd64.iso.sha256
``` ```
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI): Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
```bash ```bash
xorriso -indev virtuality-0.12.1-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI xorriso -indev virtuality-0.12.8-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.12.1-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml xorriso -osirrox on -indev virtuality-0.12.8-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
``` ```
--- ---
@@ -182,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.12.1-ubuntu-26.04-amd64.iso -extract /au
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`): - **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
```bash ```bash
sudo dd if=dist/virtuality-0.12.1-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync sudo dd if=dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
``` ```
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**. Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
Двоичные данные
Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 203 KiB

После

Ширина:  |  Высота:  |  Размер: 266 KiB

+56 -2
Просмотреть файл
@@ -1,3 +1,4 @@
import uuid
from pathlib import Path from pathlib import Path
from urllib.parse import unquote from urllib.parse import unquote
@@ -101,7 +102,7 @@ def test_dashboard_uses_plain_language(logged_in):
def test_vm_create_preselects_image(logged_in, data_dirs): def test_vm_create_preselects_image(logged_in, data_dirs):
(data_dirs["iso"] / "debian.iso").write_bytes(b"iso") (data_dirs["iso"] / "debian.iso").write_bytes(b"iso")
html = logged_in.get("/vm/create?iso=debian.iso").text html = logged_in.get("/vm/create?iso=debian.iso").text
assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" selected' in html assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" data-os="" data-login="" checked' in html
def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch): def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch):
@@ -141,7 +142,10 @@ def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch):
assert cmd[0] == "virt-install" assert cmd[0] == "virt-install"
assert "--cdrom" in cmd and str(iso) in cmd assert "--cdrom" in cmd and str(iso) in cmd
assert any(part.startswith("network=virtuality-nat,model=virtio,mac=52:54:00:") for part in cmd) assert any(part.startswith("network=virtuality-nat,model=virtio,mac=52:54:00:") for part in cmd)
assert captured["operation"]["boot_order"] == "cdrom_disk" assert captured["operation"]["boot_order"] == "disk_cdrom"
# После установки гость перезагружается с диска: без особого режима установки и с диском первым в очереди.
assert cmd[cmd.index("--boot") + 1] == "hd,cdrom"
assert cmd[cmd.index("--install") + 1] == "no_install=yes"
def test_vm_create_validation_error(logged_in): def test_vm_create_validation_error(logged_in):
@@ -220,3 +224,53 @@ def test_vm_create_disk_bigger_than_free_space_needs_confirmation(logged_in, dat
response = logged_in.post("/vm/create", data={**form, "name": "small", "disk_size": 20}, follow_redirects=False) response = logged_in.post("/vm/create", data={**form, "name": "small", "disk_size": 20}, follow_redirects=False)
assert response.status_code == 303 and "--check" not in started[1] assert response.status_code == 303 and "--check" not in started[1]
def failed_create(data_dirs, name, disk=True, **extra):
"""Операция создания машины, у которой virt-install завершился с ошибкой."""
disk_path = data_dirs["images"] / f"{name}.qcow2"
if disk:
disk_path.write_bytes(b"QFI\xfb")
operation = {"id": str(uuid.uuid4()), "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "", "created_at": app.utc_now(), "updated_at": app.utc_now(), "vm_name": name, "disk_path": str(disk_path), "network_mode": "bridge", **extra}
app.write_operation(operation)
app.run_operation_worker(operation["id"], ["bash", "-c", "echo 'ERROR The requested volume capacity will exceed the available pool space'; exit 1"])
return app.read_operation(operation["id"]), disk_path
def test_failed_vm_create_cleans_up_and_offers_retry(logged_in, data_dirs):
"""Страница упавшей задачи предлагала «Открыть экран машины», хотя машины нет; недосозданный диск мешал повтору."""
operation, disk_path = failed_create(data_dirs, "smk", iso_path=str(data_dirs["iso"] / "smk-server-_1.iso"))
assert operation["status"] == "error" and "ничего не осталось" in operation["message"]
assert not disk_path.exists() and "Недосозданный диск smk.qcow2 удалён" in operation["log_tail"]
html = logged_in.get(f"/operations/{operation['id']}").text
assert 'data-op-done="success" hidden' in html
assert 'href="/vm/create?iso=smk-server-_1.iso"' in html and "Создать заново" in html
def test_failed_create_of_existing_vm_keeps_its_disk(logged_in, data_dirs):
operation, disk_path = failed_create(data_dirs, "web01") # машина определена в libvirt: её диск не трогаем
assert disk_path.exists() and "ничего не осталось" not in operation["message"]
html = logged_in.get(f"/operations/{operation['id']}").text
assert "Создать заново" not in html and 'href="/vm/web01">Перейти к машине' in html
def test_operation_page_reveals_only_buttons_for_its_result():
script = (Path(__file__).resolve().parents[1] / "web" / "static" / "panel.js").read_text()
assert '$$(`[data-op-done="${status}"]`)' in script and "$$('[data-op-done]')" not in script
def test_console_websocket_path_works_with_old_and_new_novnc(tmp_path, monkeypatch):
"""noVNC 1.6 (Ubuntu 26.04) считает path от /novnc/vnc.html: без "../" экран стучался в /novnc/console/ws/… и не подключался."""
import posixpath
from urllib.parse import parse_qs, urljoin, urlsplit
(tmp_path / "vnc.html").write_text("<html></html>")
monkeypatch.setattr(app, "NOVNC_DIR", tmp_path)
monkeypatch.setattr(app, "vm_vnc_display", lambda name: "127.0.0.1:0")
info = app.console_info("web01")
page = "https://panel.test:8443" + info["url"]
path = parse_qs(urlsplit(page).query)["path"][0]
token = path.rsplit("/", 1)[1]
assert app.console_serializer.loads(token) == {"vm": "web01", "port": 5900}
assert urlsplit(urljoin(page, path)).path == f"/console/ws/{token}" # noVNC 1.6: new URL(path, location.href)
assert posixpath.normpath("/" + path) == f"/console/ws/{token}" # noVNC 1.3: "wss://host:port/" + path
+72 -4
Просмотреть файл
@@ -1,7 +1,7 @@
"""Regressions for the defects found by the 0.11 audit.""" """Regressions for the defects found by the 0.11 audit."""
import json import json
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote, unquote
import app import app
import conftest import conftest
@@ -55,20 +55,88 @@ def test_vm_action_success_redirects_without_error(logged_in, data_dirs, monkeyp
def test_vm_delete_undefines_uefi_machines(logged_in, data_dirs, monkeypatch): def test_vm_delete_undefines_uefi_machines(logged_in, data_dirs, monkeypatch):
import core
calls = recording(monkeypatch, app) calls = recording(monkeypatch, app)
core_calls = recording(monkeypatch, core)
disk = data_dirs["images"] / "web01.qcow2"
disk.write_bytes(b"QFI\xfb")
response = logged_in.post("/vm/web01/delete", follow_redirects=False) response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert response.headers["location"].startswith("/?message=") assert response.headers["location"].startswith("/?message=")
assert find(calls, "virsh", "destroy", "web01") assert find(calls, "virsh", "destroy", "web01")
undefine = find(calls, "virsh", "undefine", "web01")[0] undefine = find(core_calls, "virsh", "undefine", "web01")[0]
assert "--nvram" in undefine and "--remove-all-storage" in undefine and "--snapshots-metadata" in undefine assert "--nvram" in undefine and "--snapshots-metadata" in undefine
assert not disk.exists() and find(core_calls, "virsh", "pool-refresh", "virtuality-images")
def test_vm_delete_keeps_installer_iso_and_foreign_disks(logged_in, data_dirs, monkeypatch):
"""--remove-all-storage удалял вставленный ISO: загруженные образы пропадали вместе с машиной."""
import core
iso = data_dirs["iso"] / "smk-server-_1.iso"
iso.write_bytes(b"ISO")
disk = data_dirs["images"] / "web01.qcow2"
disk.write_bytes(b"QFI\xfb")
foreign = data_dirs["disk_images"] / "shared.raw"
foreign.write_bytes(b"raw")
listing = f" Type Device Target Source\n----\n file disk vda {disk}\n file disk vdb {foreign}\n file cdrom sda {iso}\n"
recording(monkeypatch, app)
core_calls = recording(monkeypatch, core, [(["virsh", "domblklist"], conftest.fake_result(listing))])
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert not disk.exists() and iso.exists() and foreign.exists()
assert "--remove-all-storage" not in find(core_calls, "virsh", "undefine", "web01")[0]
assert str(foreign) in unquote(response.headers["location"])
def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch): def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch):
recording(monkeypatch, app, [(["virsh", "undefine"], conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: cannot undefine domain with nvram"))]) import core
recording(monkeypatch, app)
recording(monkeypatch, core, [(["virsh", "undefine"], conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: cannot undefine domain with nvram"))])
response = logged_in.post("/vm/web01/delete", follow_redirects=False) response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert response.headers["location"].startswith("/vm/web01?error=") assert response.headers["location"].startswith("/vm/web01?error=")
def test_vm_delete_stops_paused_machine_first(logged_in, data_dirs, monkeypatch):
import core
calls = recording(monkeypatch, app)
core_calls = recording(monkeypatch, core, [(["virsh", "domstate"], conftest.fake_result("paused"))])
logged_in.post("/vm/web01/delete", follow_redirects=False)
assert find(calls, "virsh", "destroy", "web01") and find(core_calls, "virsh", "undefine", "web01")
def test_vm_delete_waits_for_running_task(logged_in, data_dirs, monkeypatch):
import core
calls = recording(monkeypatch, app)
backup = core.new_operation("backup", "Резервная копия web01", vm_name="web01")
core.update_operation(backup, status="running")
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert "Резервная копия web01" in unquote(response.headers["location"])
assert not find(calls, "virsh", "undefine") and not find(calls, "virsh", "destroy")
def test_vm_delete_removes_its_port_forwards(logged_in, data_dirs, monkeypatch):
recording(monkeypatch, app)
rule = {"guest_ip": "192.168.100.51", "external_port_start": 2222, "external_port_end": 2222, "guest_port_start": 22, "guest_port_end": 22, "protocol": "tcp"}
data_dirs["network"].joinpath("port_forwards.json").write_text(json.dumps([
{"id": "1", "vm_name": "web01", **rule},
{"id": "2", "vm_name": "db01", **rule, "external_port_start": 2223, "external_port_end": 2223, "guest_ip": "192.168.100.52"},
]))
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert "убраны пробросы портов: 1" in unquote(response.headers["location"])
assert [item["vm_name"] for item in network_core.load_port_forwards()] == ["db01"]
assert "2222" not in (data_dirs["nft"] / "virtuality.nft").read_text()
def test_delete_is_offered_for_running_machine(logged_in):
html = logged_in.get("/vm/web01").text # в тестах web01 работает
assert 'action="/vm/web01/delete"' in html and "она будет сразу выключена" in html
dashboard = logged_in.get("/").text
assert 'action="/vm/web01/delete"' in dashboard and 'action="/vm/db01/delete"' in dashboard
# ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158) # ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158)
def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch): def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch):
iso = data_dirs["iso"] / "debian.iso" iso = data_dirs["iso"] / "debian.iso"
+6 -6
Просмотреть файл
@@ -420,7 +420,7 @@ def test_restore_requires_replace_confirmation(fast_worker):
assert exc.value.code == "exists" assert exc.value.code == "exists"
UNDEFINE = ["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"] UNDEFINE = ["virsh", "undefine", "web01", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"]
def test_restore_replaces_existing_vm_only_after_disks_are_ready(fast_worker, monkeypatch): def test_restore_replaces_existing_vm_only_after_disks_are_ready(fast_worker, monkeypatch):
@@ -464,23 +464,23 @@ def test_restore_refuses_when_space_is_tight_even_if_old_vm_would_free_it(fast_w
assert exc.value.code == "space" and "рядом со старой машиной" not in str(exc.value) assert exc.value.code == "space" and "рядом со старой машиной" not in str(exc.value)
def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch): def test_restore_keeps_inserted_iso(fast_worker, monkeypatch):
make_backup(fast_worker) make_backup(fast_worker)
iso = fast_worker["iso"] / "debian.iso"
iso.write_bytes(b"ISO")
virsh = FakeVirsh(running=False) virsh = FakeVirsh(running=False)
base = virsh.__call__ base = virsh.__call__
def with_iso(cmd, timeout=12, **kw): def with_iso(cmd, timeout=12, **kw):
if cmd[:2] == ["virsh", "domblklist"]: if cmd[:2] == ["virsh", "domblklist"]:
virsh.calls.append(list(cmd)) virsh.calls.append(list(cmd))
return fake_result(DOMBLKLIST_DETAILS.replace("sda -", "sda /var/lib/virtuality/iso/debian.iso")) return fake_result(DOMBLKLIST_DETAILS.replace("/var/lib/virtuality/images", str(core.IMAGES_DIR)).replace("sda -", f"sda {iso}"))
return base(cmd, timeout, **kw) return base(cmd, timeout, **kw)
monkeypatch.setattr(core, "run_cmd", with_iso) monkeypatch.setattr(core, "run_cmd", with_iso)
monkeypatch.setattr(backups, "stream_cmd", fake_stream()) monkeypatch.setattr(backups, "stream_cmd", fake_stream())
backups.start_restore("web01", "20260924-0300", "web01", True) backups.start_restore("web01", "20260924-0300", "web01", True)
eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"] assert UNDEFINE in virsh.calls and iso.exists()
assert eject in virsh.calls
assert virsh.calls.index(eject) < virsh.calls.index(UNDEFINE)
def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker): def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker):
+54 -3
Просмотреть файл
@@ -1,7 +1,10 @@
"""Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses.""" """Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses."""
import shlex import shlex
import pytest
import app import app
import catalog
import cloudinit import cloudinit
import conftest import conftest
import network_core import network_core
@@ -118,7 +121,7 @@ def test_windows11_gets_uefi_tpm_sata_and_e1000e(logged_in, data_dirs, monkeypat
assert response.status_code == 303 assert response.status_code == 303
cmd = captured["cmd"] cmd = captured["cmd"]
assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11" assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11"
assert cmd[cmd.index("--boot") + 1] == "uefi,cdrom,hd" assert cmd[cmd.index("--boot") + 1] == "uefi,hd,cdrom"
assert "--tpm" in cmd and "backend.version=2.0" in cmd[cmd.index("--tpm") + 1] assert "--tpm" in cmd and "backend.version=2.0" in cmd[cmd.index("--tpm") + 1]
assert "bus=sata" in cmd[cmd.index("--disk") + 1] and "model=e1000e" in cmd[cmd.index("--network") + 1] assert "bus=sata" in cmd[cmd.index("--disk") + 1] and "model=e1000e" in cmd[cmd.index("--network") + 1]
assert "--channel" not in cmd # Windows has no guest agent without extra drivers assert "--channel" not in cmd # Windows has no guest agent without extra drivers
@@ -129,7 +132,7 @@ def test_linux_installer_detects_os_and_uses_virtio(logged_in, data_dirs, monkey
iso.write_bytes(b"iso") iso.write_bytes(b"iso")
response, captured = create(logged_in, monkeypatch, iso_form(iso)) response, captured = create(logged_in, monkeypatch, iso_form(iso))
cmd = captured["cmd"] cmd = captured["cmd"]
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "cdrom,hd" assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "hd,cdrom"
assert "bus=virtio" in cmd[cmd.index("--disk") + 1] and "model=virtio" in cmd[cmd.index("--network") + 1] and "--tpm" not in cmd assert "bus=virtio" in cmd[cmd.index("--disk") + 1] and "model=virtio" in cmd[cmd.index("--network") + 1] and "--tpm" not in cmd
assert cmd[cmd.index("--channel") + 1] == "unix,target.type=virtio,target.name=org.qemu.guest_agent.0" assert cmd[cmd.index("--channel") + 1] == "unix,target.type=virtio,target.name=org.qemu.guest_agent.0"
assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400 assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400
@@ -146,7 +149,55 @@ def test_reservation_is_reused_and_released(data_dirs, monkeypatch):
def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch): def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch):
calls = recording(monkeypatch, app, []) import core
recording(monkeypatch, app, [])
calls = recording(monkeypatch, core, [])
net_calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))]) net_calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
assert logged_in.post("/vm/web01/delete", follow_redirects=False).status_code == 303 assert logged_in.post("/vm/web01/delete", follow_redirects=False).status_code == 303
assert find(calls, "virsh", "undefine") and find(net_calls, "virsh", "net-update", "virtuality-nat", "delete") assert find(calls, "virsh", "undefine") and find(net_calls, "virsh", "net-update", "virtuality-nat", "delete")
# ---------------------------------------------------------------- тип системы по установочному ISO
def fake_iso(path, label):
"""Минимальный ISO 9660: первичный дескриптор тома с меткой по смещению 0x8028."""
descriptor = bytearray(0x800)
descriptor[0], descriptor[1:6], descriptor[6] = 1, b"CD001", 1
descriptor[0x28:0x48] = label.encode().ljust(32)
path.write_bytes(bytes(0x8000) + bytes(descriptor))
return path
@pytest.mark.parametrize("label, name, expected", [
("19044_4046___WIN10_IOT_LTSC", "19044.4046---Win10-IoT-LTSC.iso", "windows10"),
("CCCOMA_X64FRE_RU-RU_DV9", "Win11_24H2_Russian_x64.iso", "windows11"),
("CCCOMA_X64FRE_EN-US_DV9", "windows.iso", "windows11"), # версия неизвестна: UEFI + TPM подходят и для 10
("SSS_X64FREE_EN-US_DV9", "20348.169.210806-2348.fe_release_SERVER_EVAL_x64FRE_en-us.iso", "windows10"),
("SSS_X64FREV_EN-US_DV9", "26100.1742.240906-0331.ge_release_SERVER_EVAL_x64FRE_en-us.iso", "windows11"),
("Ubuntu-Server 26.04 LTS amd64", "ubuntu-26.04-live-server-amd64.iso", ""),
("", "debian-13.1.0-amd64-netinst.iso", ""),
("smk-server", "smk-server-_1.iso", ""),
])
def test_guess_os_type_from_iso(label, name, expected):
assert catalog.guess_os_type(label, name) == expected
def test_windows_iso_preselects_windows_type(logged_in, data_dirs):
"""Машина с Windows 10 создалась как Linux: диск virtio, установщик Windows его не видел."""
fake_iso(data_dirs["iso"] / "19044.4046---Win10-IoT-LTSC.iso", "19044_4046___WIN10_IOT_LTSC")
fake_iso(data_dirs["iso"] / "ubuntu.iso", "Ubuntu-Server 26.04 LTS amd64")
assert app.iso_volume_label(data_dirs["iso"] / "ubuntu.iso") == "Ubuntu-Server 26.04 LTS amd64"
html = logged_in.get("/vm/create?iso=19044.4046---Win10-IoT-LTSC.iso").text
assert 'data-os="windows10" data-login="" checked' in html and 'data-label="ubuntu.iso" data-os="" data-login="" >' in html
assert 'value="windows10" checked' in html and 'value="linux" checked' not in html
def test_images_are_offered_as_cards_with_system_size_and_date(logged_in, data_dirs):
fake_iso(data_dirs["iso"] / "19044.4046---Win10-IoT-LTSC.iso", "19044_4046___WIN10_IOT_LTSC")
fake_iso(data_dirs["iso"] / "ubuntu-24.04.1-live-server-amd64.iso", "Ubuntu-Server 24.04.1 LTS amd64")
html = logged_in.get("/vm/create").text
assert html.count('name="iso_path"') == 2 and '<span class="tag">Windows 10</span>' in html and '<span class="tag">Ubuntu</span>' in html
assert "34 КБ · загружен" in html and "data-image-search" not in html # поиск только когда образов больше шести
for number in range(5):
fake_iso(data_dirs["iso"] / f"extra-{number}.iso", "")
assert "data-image-search" in logged_in.get("/vm/create").text
+81
Просмотреть файл
@@ -1,5 +1,86 @@
{ {
"versions": [ "versions": [
{
"version": "0.12.8",
"date": "2026-09-27",
"title": "Удобный выбор образа при создании машины",
"summary": "Установочные образы и готовые системы в форме создания машины теперь показаны карточками: какая система внутри, размер и когда образ загружен. Когда образов больше шести, появляется поиск по названию.",
"features": [
"Образы в форме создания машины показаны карточками вместо выпадающего списка",
"На карточке видно систему (Windows 10, Windows 11, Ubuntu, Debian и другие), размер и дату загрузки",
"Поиск по названию, когда образов больше шести",
"Версия обновлена до 0.12.8"
]
},
{
"version": "0.12.7",
"date": "2026-09-27",
"title": "Тип Windows выбирается сам по установочному образу",
"summary": "Если в форме создания машины выбран установщик Windows, панель узнаёт его по метке образа и имени файла и сама ставит тип Windows. Раньше по умолчанию оставался Linux, диск подключался как virtio, и установщик Windows его не видел.",
"features": [
"Установщик Windows 10, 11 и Server узнаётся по установочному образу, тип системы выбирается сам",
"Выбор можно поменять вручную, панель его больше не перебивает",
"Версия обновлена до 0.12.7"
]
},
{
"version": "0.12.6",
"date": "2026-09-27",
"title": "Установочные образы больше не пропадают при удалении машины",
"summary": "При удалении машины вместе с ней удалялся и вставленный в её привод установочный ISO, поэтому загруженные образы исчезали. Теперь удаляются только диски самой машины, а образы остаются для следующих установок, как в Proxmox.",
"features": [
"Удаление машины больше не удаляет установочный ISO из её привода",
"Удаляются только диски машины из папки машин, диски в других местах остаются, панель называет их в сообщении",
"То же при восстановлении из копии с заменой машины",
"Версия обновлена до 0.12.6"
]
},
{
"version": "0.12.5",
"date": "2026-09-27",
"title": "Удаление машины в любом состоянии",
"summary": "Пункт «Удалить машину» теперь есть всегда, а не только у выключенной машины. Работающую или приостановленную машину панель сначала выключит, а подтверждение честно об этом предупреждает. Вместе с машиной удаляются её пробросы портов.",
"features": [
"«Удалить машину» доступно в меню машины и в списке машин при любом состоянии",
"Работающая или приостановленная машина перед удалением выключается",
"Пока с машиной идёт копия, клон или другая задача, удалить её нельзя: панель просит дождаться завершения",
"Вместе с машиной удаляются её пробросы портов, чтобы они не вели на адрес, который достанется другой машине",
"Версия обновлена до 0.12.5"
]
},
{
"version": "0.12.4",
"date": "2026-09-27",
"title": "Машина с установочного образа после установки перезагружается сама",
"summary": "Раньше машина, созданная с ISO, после установки системы выключалась вместо перезагрузки, а при следующем запуске снова загружалась с установочного образа. Теперь она создаётся сразу в рабочем виде, диск стоит первым в порядке загрузки, и после установки машина перезагружается уже в установленную систему.",
"features": [
"После установки с ISO машина перезагружается сама, а не выключается",
"Для машин с ISO диск стоит первым в порядке загрузки: пока он пустой, машина загружается с образа, а после установки с диска и повторно в установщик не попадает",
"Версия обновлена до 0.12.4"
]
},
{
"version": "0.12.3",
"date": "2026-09-27",
"title": "Экран машины снова подключается",
"summary": "На Ubuntu 26.04 экран машины в браузере не подключался: новая версия noVNC обращалась не по тому адресу. Теперь экран работает и с новой, и со старой версией noVNC.",
"features": [
"Экран машины в браузере подключается на Ubuntu 26.04 и Debian 13 (noVNC 1.6), как и на Ubuntu 24.04 (noVNC 1.3)",
"Версия обновлена до 0.12.3"
]
},
{
"version": "0.12.2",
"date": "2026-09-27",
"title": "Понятный итог, если машину создать не удалось",
"summary": "Если создание машины сорвалось, панель больше не предлагает открыть экран несуществующей машины. Она пишет, что на сервере от неё ничего не осталось, и даёт кнопку «Создать заново» с тем же образом. Недосозданный диск удаляется сам, поэтому то же название можно использовать сразу.",
"features": [
"Кнопки «Открыть экран машины» и «Перейти к машине» появляются только после успешного создания",
"После неудачного создания: кнопка «Создать заново» с тем же установочным или готовым образом",
"Недосозданный диск удаляется сам, повторное создание под тем же названием больше не упирается в «Диск уже существует»",
"Версия обновлена до 0.12.2"
]
},
{ {
"version": "0.12.1", "version": "0.12.1",
"date": "2026-09-27", "date": "2026-09-27",
+78 -16
Просмотреть файл
@@ -294,6 +294,17 @@ def progress_from_line(current: int, line: str) -> int:
return current return current
def remove_unfinished_disk(operation_id: str, disk_path: str) -> None:
"""Машина не появилась, а её диск (из готового образа он создаётся до virt-install) остался:
удаляем его, иначе повторное создание под тем же названием упрётся в «Диск уже существует»."""
disk = Path(disk_path)
if not disk_path or disk.parent.resolve() != IMAGES_DIR.resolve() or not disk.is_file():
return
disk.unlink(missing_ok=True)
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
append_operation_log(operation_id, f"Недосозданный диск {disk.name} удалён.")
def run_operation_worker(operation_id: str, cmd: list[str]) -> None: def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
operation = read_operation(operation_id) operation = read_operation(operation_id)
if not operation: if not operation:
@@ -322,7 +333,13 @@ def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
append_operation_log(operation_id, f"virt-install завершился с ошибкой. Exit code: {exit_code}") append_operation_log(operation_id, f"virt-install завершился с ошибкой. Exit code: {exit_code}")
if fresh.get("vm_name") and fresh.get("network_mode") == "nat": if fresh.get("vm_name") and fresh.get("network_mode") == "nat":
network_core.release_nat_address(fresh["vm_name"]) network_core.release_nat_address(fresh["vm_name"])
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"virt-install завершился с ошибкой: {exit_code}", finished_at=utc_now()) vm_left = bool(fresh.get("vm_name")) and vm_exists(fresh["vm_name"])
if not vm_left:
remove_unfinished_disk(operation_id, fresh.get("disk_path") or "")
message = "Машину создать не удалось, причина в журнале ниже."
if not vm_left:
message += " На сервере от неё ничего не осталось, её можно создать заново под тем же названием."
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=message, finished_at=utc_now())
except Exception as exc: except Exception as exc:
fresh = read_operation(operation_id) or operation fresh = read_operation(operation_id) or operation
append_operation_log(operation_id, f"Ошибка запуска операции: {exc}") append_operation_log(operation_id, f"Ошибка запуска операции: {exc}")
@@ -536,6 +553,19 @@ def parse_pool_list() -> list[dict[str, str]]:
return rows return rows
def iso_volume_label(path: Path) -> str:
"""Метка тома ISO 9660: 32 байта в первичном дескрипторе по смещению 0x8028."""
try:
with path.open("rb") as handle:
handle.seek(0x8000)
descriptor = handle.read(0x48)
except OSError:
return ""
if descriptor[1:6] != b"CD001":
return ""
return descriptor[0x28:0x48].decode("ascii", "replace").strip()
def list_iso_files() -> list[dict[str, str]]: def list_iso_files() -> list[dict[str, str]]:
ISO_DIR.mkdir(parents=True, exist_ok=True) ISO_DIR.mkdir(parents=True, exist_ok=True)
files = [] files = []
@@ -547,7 +577,12 @@ def list_iso_files() -> list[dict[str, str]]:
except OSError: except OSError:
size_mb = 0 size_mb = 0
updated = "unknown" updated = "unknown"
files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated}) label = iso_volume_label(item)
files.append({
"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated,
"os_type": catalog.guess_os_type(label, item.name), "system": catalog.iso_system_name(label, item.name),
"size_label": presenters.format_bytes(stat.st_size) if size_mb else "", "date_label": presenters.short_date(stat.st_mtime) if size_mb else "",
})
return files return files
@@ -605,7 +640,8 @@ def list_disk_image_files() -> list[dict[str, str]]:
size_gb = 0 size_gb = 0
updated = "unknown" updated = "unknown"
entry = catalog.entry_for_filename(item.name) entry = catalog.entry_for_filename(item.name)
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated, "login": cloudinit.default_user(item.name), "title": entry["title"] if entry else "", "catalog_id": entry["id"] if entry else ""}) files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated, "login": cloudinit.default_user(item.name), "title": entry["title"] if entry else "", "catalog_id": entry["id"] if entry else "",
"size_label": presenters.format_bytes(stat.st_size) if size_gb else "", "date_label": presenters.short_date(stat.st_mtime) if size_gb else ""})
return files return files
@@ -922,7 +958,9 @@ def console_info(name: str) -> dict[str, Any]:
url = None url = None
if port and has_novnc: if port and has_novnc:
token = console_serializer.dumps({"vm": name, "port": port}) token = console_serializer.dumps({"vm": name, "port": port})
url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=console/ws/{token}" # "../": noVNC 1.6 (Ubuntu 26.04) строит адрес от своей страницы /novnc/vnc.html, а 1.3 (Ubuntu 24.04)
# приклеивает path к корню сайта; в обоих случаях получается /console/ws/<токен>.
url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=../console/ws/{token}"
return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url} return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}
@@ -1253,12 +1291,12 @@ def vm_boot_order_options() -> list[dict[str, str]]:
def normalize_boot_order(value: str, source_type: str) -> str: def normalize_boot_order(value: str, source_type: str) -> str:
"""auto для ISO — сначала диск, потом CD, как в Proxmox: пустой диск не загрузочный, и машина стартует
с установщика, а после установки — уже с диска, и повторно в установщик не попадает."""
value = (value or "auto").strip() value = (value or "auto").strip()
if value == "auto":
return "cdrom_disk" if source_type == "iso" else "disk"
if value in ("disk", "cdrom_disk", "disk_cdrom"): if value in ("disk", "cdrom_disk", "disk_cdrom"):
return value return value
return "cdrom_disk" if source_type == "iso" else "disk" return "disk_cdrom" if source_type == "iso" else "disk"
def virt_boot_arg(boot_order: str, is_arm: bool) -> str: def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
@@ -1737,7 +1775,18 @@ def operation_detail_page(request: Request, operation_id: str):
operation = read_operation(operation_id) operation = read_operation(operation_id)
if not operation: if not operation:
return RedirectResponse(url="/operations", status_code=303) return RedirectResponse(url="/operations", status_code=303)
return templates.TemplateResponse(request, "operation_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css}) vm_name = str(operation.get("vm_name") or "")
vm_present = valid_vm_name(vm_name) and vm_exists(vm_name)
return templates.TemplateResponse(request, "operation_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css, "vm_present": vm_present, "retry_url": vm_create_retry_url(operation)})
def vm_create_retry_url(operation: dict[str, Any]) -> str:
"""Форма создания машины с тем же установочным образом или готовым образом, что у неудачной попытки."""
if operation.get("iso_path"):
return "/vm/create?iso=" + quote(Path(operation["iso_path"]).name)
if operation.get("disk_image_path"):
return "/vm/create?image=" + quote(Path(operation["disk_image_path"]).name)
return "/vm/create"
@app.get("/api/operations") @app.get("/api/operations")
@@ -1789,6 +1838,7 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""):
form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE} form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE}
if iso: if iso:
form["iso_path"] = str(ISO_DIR / Path(iso).name) form["iso_path"] = str(ISO_DIR / Path(iso).name)
form["os_type"] = catalog.guess_os_type(iso_volume_label(ISO_DIR / Path(iso).name), Path(iso).name) or "linux"
if image: if image:
form["disk_image_path"] = str(DISK_IMAGES_DIR / Path(image).name) form["disk_image_path"] = str(DISK_IMAGES_DIR / Path(image).name)
return vm_form_context(request, form=form) return vm_form_context(request, form=form)
@@ -1918,13 +1968,16 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
steps.append(" ".join(shlex.quote(part) for part in virt)) steps.append(" ".join(shlex.quote(part) for part in virt))
cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)] cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)]
else: else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics] # no_install: машина сразу создаётся в рабочем виде, с образом в приводе. Без этого virt-install ставит
# на время установки «перезагрузка = выключение» и сам должен перезапустить машину, а он не ждёт
# (--noautoconsole) — после установки машина просто выключалась.
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--install", "no_install=yes", "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics]
if too_big: if too_big:
cmd += ["--check", "disk_size=off"] # подтверждено пользователем: virt-install иначе откажется cmd += ["--check", "disk_size=off"] # подтверждено пользователем: virt-install иначе откажется
success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "") success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "")
operation_id = str(uuid.uuid4()) operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "os_type": os_type, "cloud_init_user": ci_user if use_cloud_init else "", "nat_ip": nat_ip or "", "seed_file": seed_file, "success_message": success_message, "cmd": " ".join(cmd)} operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "disk_image_path": disk_image_path if source_type == "disk_image" else "", "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "os_type": os_type, "cloud_init_user": ci_user if use_cloud_init else "", "nat_ip": nat_ip or "", "seed_file": seed_file, "success_message": success_message, "cmd": " ".join(cmd)}
start_background_operation(operation, cmd) start_background_operation(operation, cmd)
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303) return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@@ -2061,14 +2114,23 @@ def vm_action(request: Request, name: str, action: str):
if action in ("shutdown", "destroy", "delete"): if action in ("shutdown", "destroy", "delete"):
backups.cancel_late_restart(name) backups.cancel_late_restart(name)
if action == "delete": if action == "delete":
if vm_state(name) == "running": import core
busy = core.active_operations_for(name)
if busy:
return redirect_with_message(f"/vm/{name}", "error", f"С машиной сейчас идёт задача «{busy[0].get('title') or 'операция'}». Дождитесь её завершения и удалите машину снова.")
if core.vm_is_running(name): # и на паузе тоже: undefine активной машины оставил бы её работать без настроек
run_cmd(["virsh", "destroy", name], timeout=20) run_cmd(["virsh", "destroy", name], timeout=20)
# --nvram: UEFI machines keep firmware variables that block a plain undefine. # Установочные ISO и образы систем остаются: удаляются только диски самой машины.
result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120) ok, error, _removed, kept = core.undefine_vm(name)
if not result["ok"]: if not ok:
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}") return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {error}")
network_core.release_nat_address(name) network_core.release_nat_address(name)
return redirect_with_message("/", "message", f"Машина {name} удалена") forwards = network_core.remove_vm_forwards(name)
message = f"Машина {name} удалена" + (f", вместе с ней убраны пробросы портов: {forwards}" if forwards else "")
if kept:
message += ". Не удалены диски вне папки машин: " + ", ".join(str(path) for path in kept)
return redirect_with_message("/", "message", message)
if action not in allowed: if action not in allowed:
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400) return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
result = run_cmd(allowed[action], timeout=30) result = run_cmd(allowed[action], timeout=30)
+38
Просмотреть файл
@@ -3,6 +3,7 @@
Each entry points to the vendor's official cloud image. Images boot in seconds Each entry points to the vendor's official cloud image. Images boot in seconds
and are configured through cloud-init (user, password, SSH key) — no installer. and are configured through cloud-init (user, password, SSH key) — no installer.
""" """
import re
from typing import Any from typing import Any
# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13. # osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13.
@@ -69,6 +70,43 @@ OS_TYPES: list[dict[str, Any]] = [
] ]
# Установщик Windows узнаём по метке тома ISO (у Microsoft вида CCCOMA_X64FRE_RU-RU_DV9) или по имени файла.
WINDOWS_RE = re.compile(r"\bWIN(?:DOWS)?\s?(?:1[01]|7|8|SERVER)\b|\bWINDOWS\b|\b(?:X64|X86|A64|ARM64)FRE[EV]?\b")
WINDOWS11_RE = re.compile(r"\bWIN(?:DOWS)?\s?11\b|\bSERVER\s?2025\b|\b(?:22000|22621|22631|26100|26200)\b")
WINDOWS10_RE = re.compile(r"\bWIN(?:DOWS)?\s?(?:10|7|8)\b|\bLTSC\b|\bSERVER\s?20(?:16|19|22)\b|\b1\d{4}\b|\b20348\b")
def guess_os_type(volume_label: str, filename: str) -> str:
"""Тип системы для установочного ISO: windows11 / windows10, если это установщик Windows, иначе "".
Windows неизвестной версии — windows11: UEFI и TPM подходят и для Windows 10, а без них не встанет 11."""
text = re.sub(r"[^A-Z0-9]+", " ", f"{volume_label} {filename}".upper())
if not WINDOWS_RE.search(text):
return ""
if WINDOWS11_RE.search(text):
return "windows11"
return "windows10" if WINDOWS10_RE.search(text) else "windows11"
# Название системы для карточки образа в форме создания машины.
DISTROS = (
("UBUNTU", "Ubuntu"), ("DEBIAN", "Debian"), ("FEDORA", "Fedora"), ("ALPINE", "Alpine"), ("CENTOS", "CentOS"),
("ROCKY", "Rocky Linux"), ("ALMALINUX", "AlmaLinux"), ("OPENSUSE", "openSUSE"), ("ARCHLINUX", "Arch Linux"),
("LINUXMINT", "Linux Mint"), ("KALI", "Kali Linux"), ("ASTRA", "Astra Linux"), ("REDOS", "РЕД ОС"),
("PROXMOX", "Proxmox"), ("TRUENAS", "TrueNAS"), ("FREEBSD", "FreeBSD"), ("PFSENSE", "pfSense"),
("OPNSENSE", "OPNsense"), ("OPENWRT", "OpenWrt"),
)
def iso_system_name(volume_label: str, filename: str) -> str:
"""«Windows 10», «Ubuntu»… или "", если систему по образу не узнать."""
guess = guess_os_type(volume_label, filename)
if guess:
return "Windows 11" if guess == "windows11" else "Windows 10"
words = set(re.sub(r"[^A-Z0-9]+", " ", f"{volume_label} {filename}".upper()).split())
return next((title for key, title in DISTROS if key in words), "")
def cloud_image(image_id: str) -> dict[str, Any] | None: def cloud_image(image_id: str) -> dict[str, Any] | None:
return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None) return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None)
+37
Просмотреть файл
@@ -227,6 +227,43 @@ def vm_is_running(name: str) -> bool:
return vm_state(name) in ("running", "idle", "blocked", "paused", "pmsuspended", "in shutdown") return vm_state(name) in ("running", "idle", "blocked", "paused", "pmsuspended", "in shutdown")
def vm_disk_files(name: str) -> tuple[list[Path], list[Path]]:
"""Файлы дисков машины: (свои — в IMAGES_DIR, чужие — где-то ещё). Приводы CD/DVD сюда не входят."""
own: list[Path] = []
foreign: list[Path] = []
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
for disk in presenters.parse_domblklist(result.get("stdout") or ""):
if disk["device"] == "disk" and disk["source"]:
path = Path(disk["source"])
(own if IMAGES_DIR.resolve() in path.resolve().parents else foreign).append(path)
return own, foreign
def undefine_vm(name: str) -> tuple[bool, str, list[Path], list[Path]]:
"""Удалить машину и файлы её дисков из IMAGES_DIR. Возвращает (ok, ошибка, удалённые, оставленные).
Без `--remove-all-storage`: он удаляет все тома libvirt, подключённые к машине, в том числе
вставленный установочный ISO из пула virtuality-iso. Диски вне IMAGES_DIR не трогаем."""
own, foreign = vm_disk_files(name) # после undefine libvirt о дисках машины уже не расскажет
# --nvram: UEFI machines keep firmware variables that block a plain undefine.
result = run_cmd(["virsh", "undefine", name, "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120)
if not result["ok"]:
return False, cmd_error(result, "virsh undefine"), [], []
removed = []
for disk in own:
try:
disk.unlink()
except FileNotFoundError:
continue
except OSError:
foreign.append(disk)
continue
removed.append(disk)
if removed:
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
return True, "", removed, foreign
# ---------------------------------------------------------------- operations # ---------------------------------------------------------------- operations
def operation_meta_path(operation_id: str) -> Path: def operation_meta_path(operation_id: str) -> Path:
return OPERATIONS_DIR / f"{operation_id}.json" return OPERATIONS_DIR / f"{operation_id}.json"
+5 -16
Просмотреть файл
@@ -140,13 +140,6 @@ def vm_disks(name: str) -> list[dict[str, str]]:
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "disk" and disk["source"]] return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "disk" and disk["source"]]
def vm_cdroms_with_media(name: str) -> list[dict[str, str]]:
result = run(["virsh", "domblklist", name, "--details"], timeout=10)
if not result["ok"]:
return []
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "cdrom" and disk["source"]]
def existing_parent(path: Path) -> Path: def existing_parent(path: Path) -> Path:
"""Сам путь или ближайший существующий родитель — чтобы спросить у ФС о разделе.""" """Сам путь или ближайший существующий родитель — чтобы спросить у ФС о разделе."""
target = path target = path
@@ -691,16 +684,12 @@ def remove_vm(operation: dict[str, Any], name: str) -> None:
result = run(["virsh", "destroy", name], timeout=30) result = run(["virsh", "destroy", name], timeout=30)
if not result["ok"]: if not result["ok"]:
raise BackupError("Не удалось выключить старую машину: " + core.cmd_error(result, "virsh destroy")) raise BackupError("Не удалось выключить старую машину: " + core.cmd_error(result, "virsh destroy"))
for cdrom in vm_cdroms_with_media(name): ok, error, _removed, kept = core.undefine_vm(name) # вставленный ISO остаётся на месте
# Иначе `undefine --remove-all-storage` удалит и вставленный ISO-образ. if not ok:
result = run(["virsh", "change-media", name, cdrom["target"], "--eject", "--config"], timeout=20) raise BackupError("Не удалось удалить старую машину: " + error)
if not result["ok"]:
raise BackupError(f"Не удалось извлечь образ из привода {cdrom['target']}: " + core.cmd_error(result, "virsh change-media"))
log(f"Образ извлечён из привода {cdrom['target']}, чтобы он не был удалён вместе с машиной.")
result = run(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"], timeout=180)
if not result["ok"]:
raise BackupError("Не удалось удалить старую машину: " + core.cmd_error(result, "virsh undefine"))
log(f"Старая машина {name} удалена вместе с дисками.") log(f"Старая машина {name} удалена вместе с дисками.")
if kept:
log("Диски вне папки машин не удалены: " + ", ".join(str(path) for path in kept))
def restore_worker(operation: dict[str, Any]) -> None: def restore_worker(operation: dict[str, Any]) -> None:
+11
Просмотреть файл
@@ -436,6 +436,17 @@ def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port
return normalize_forward(forward) return normalize_forward(forward)
def remove_vm_forwards(vm_name: str) -> int:
"""Машина удалена: её пробросы вели бы на адрес, который потом может достаться другой машине."""
items = load_port_forwards()
kept = [item for item in items if item.get('vm_name') != vm_name]
if len(kept) == len(items):
return 0
save_port_forwards(kept)
apply_port_forwards()
return len(items) - len(kept)
def delete_port_forward(forward_id: str) -> None: def delete_port_forward(forward_id: str) -> None:
items = [item for item in load_port_forwards() if item.get('id') != forward_id] items = [item for item in load_port_forwards() if item.get('id') != forward_id]
save_port_forwards(items) save_port_forwards(items)
+11
Просмотреть файл
@@ -242,6 +242,17 @@ def port_service(port: int) -> str:
return WELL_KNOWN_PORTS.get(int(port or 0), "") return WELL_KNOWN_PORTS.get(int(port or 0), "")
MONTHS_GENITIVE = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря")
def short_date(timestamp: float, now: datetime | None = None) -> str:
"""«27 сентября», для прошлых лет — «27 сентября 2025»."""
moment = datetime.fromtimestamp(timestamp)
now = now or datetime.now()
text = f"{moment.day} {MONTHS_GENITIVE[moment.month - 1]}"
return text if moment.year == now.year else f"{text} {moment.year}"
def format_bytes(value: int | float) -> str: def format_bytes(value: int | float) -> str:
value = float(value or 0) value = float(value or 0)
for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"):
+19
Просмотреть файл
@@ -333,6 +333,25 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; }
.option input:checked + .option-body .option-check { border: 5px solid var(--brand); } .option input:checked + .option-body .option-check { border: 5px solid var(--brand); }
.option-title { font-weight: 650; font-size: 14px; } .option-title { font-weight: 650; font-size: 14px; }
.option-text { color: var(--text-2); font-size: 12.5px; } .option-text { color: var(--text-2); font-size: 12.5px; }
/* Выбор образа в форме создания машины */
.image-picker { display: grid; gap: 10px; }
.image-list { display: grid; gap: 8px; max-height: 360px; overflow-y: auto; padding: 2px; }
.image-option { position: relative; display: block; cursor: pointer; }
.image-option[hidden] { display: none; }
.image-option input { position: absolute; opacity: 0; pointer-events: none; }
.image-option-body { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--surface); transition: border-color .15s, background .15s; }
.image-option:hover .image-option-body { border-color: var(--border-strong); }
.image-option input:checked + .image-option-body { border-color: var(--brand); background: color-mix(in srgb, var(--brand-soft) 55%, var(--surface)); box-shadow: 0 0 0 4px var(--focus); }
.image-option input:focus-visible + .image-option-body { box-shadow: 0 0 0 4px var(--focus); }
.image-option input:checked + .image-option-body .option-check { border: 5px solid var(--brand); }
.image-option .grow { flex: 1; display: grid; gap: 2px; min-width: 0; }
.image-title { font-weight: 650; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-meta { color: var(--text-3); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 560px) {
.image-option .list-icon { display: none; }
.image-title, .image-meta { white-space: normal; overflow-wrap: anywhere; }
}
/* Расписание копий: день недели и список машин видны, только когда нужны (без :has — видны всегда). */ /* Расписание копий: день недели и список машин видны, только когда нужны (без :has — видны всегда). */
.schedule-form:has(input[name=frequency][value=daily]:checked) [data-when=weekly], .schedule-form:has(input[name=frequency][value=daily]:checked) [data-when=weekly],
.schedule-form:has(input[name=machines][value=all]:checked) [data-when=chosen] { display: none; } .schedule-form:has(input[name=machines][value=all]:checked) [data-when=chosen] { display: none; }
+31 -7
Просмотреть файл
@@ -383,7 +383,7 @@
messageEl.textContent = op.message || ''; messageEl.textContent = op.message || '';
if (logEl) { const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; logEl.textContent = op.log_tail || ''; if (atBottom) logEl.scrollTop = logEl.scrollHeight; } if (logEl) { const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; logEl.textContent = op.log_tail || ''; if (atBottom) logEl.scrollTop = logEl.scrollHeight; }
render(); render();
if (done()) { clearInterval(timer); clearInterval(tick); if (!wasDone) { toast(status === 'success' ? 'Операция завершена' : 'Операция завершилась с ошибкой', status === 'success' ? 'success' : 'danger'); $$('[data-op-done]').forEach((el) => { el.hidden = false; }); } } if (done()) { clearInterval(timer); clearInterval(tick); if (!wasDone) { toast(status === 'success' ? 'Операция завершена' : 'Операция завершилась с ошибкой', status === 'success' ? 'success' : 'danger'); $$(`[data-op-done="${status}"]`).forEach((el) => { el.hidden = false; }); if (status === 'error' && $('[data-op-done="error"][data-op-reload]')) setTimeout(() => location.reload(), 1500); } }
} catch (_) {} } catch (_) {}
}; };
const timer = setInterval(poll, 1500); const timer = setInterval(poll, 1500);
@@ -435,7 +435,8 @@
const field = (name) => form.elements[name]; const field = (name) => form.elements[name];
const value = (name) => { const el = field(name); if (!el) return ''; if (el instanceof RadioNodeList) return el.value; return el.value; }; const value = (name) => { const el = field(name); if (!el) return ''; if (el instanceof RadioNodeList) return el.value; return el.value; };
const setSummary = (key, text) => $$(`[data-summary="${key}"]`).forEach((el) => { el.textContent = text || '—'; }); const setSummary = (key, text) => $$(`[data-summary="${key}"]`).forEach((el) => { el.textContent = text || '—'; });
const optionText = (select) => select && select.selectedIndex >= 0 ? select.options[select.selectedIndex].dataset.label || select.options[select.selectedIndex].text : ''; // Образы выбираются карточками (радиокнопки): берём отмеченную.
const picked = (name) => $(`input[name="${name}"]:checked`, form);
function sync() { function sync() {
const source = value('source_type'); const source = value('source_type');
@@ -454,9 +455,7 @@
// Default login of the chosen cloud image (ubuntu, debian, …) until the user types a name. // Default login of the chosen cloud image (ubuntu, debian, …) until the user types a name.
const login = field('ci_user'); const login = field('ci_user');
if (login && login.dataset.autoLogin === '1') { if (login && login.dataset.autoLogin === '1') {
const image = field('disk_image_path'); login.value = picked('disk_image_path')?.dataset.login || 'admin';
const option = image && image.selectedIndex >= 0 ? image.options[image.selectedIndex] : null;
login.value = option?.dataset.login || 'admin';
} }
const preset = value('preset'); const preset = value('preset');
const custom = preset === 'custom'; const custom = preset === 'custom';
@@ -469,7 +468,7 @@
$$('[data-custom-resources] input', form).forEach((input) => { input.readOnly = !custom; }); $$('[data-custom-resources] input', form).forEach((input) => { input.readOnly = !custom; });
const memory = Number(value('memory')) || 0; const memory = Number(value('memory')) || 0;
setSummary('name', value('name')); setSummary('name', value('name'));
setSummary('source', source === 'disk_image' ? optionText(field('disk_image_path')) : optionText(field('iso_path'))); setSummary('source', picked(source === 'disk_image' ? 'disk_image_path' : 'iso_path')?.dataset.label || '');
setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`); setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`);
setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`); setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`);
setSummary('disk', `${value('disk_size')} ГБ`); setSummary('disk', `${value('disk_size')} ГБ`);
@@ -478,8 +477,33 @@
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); }); $$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; }); $$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
} }
// Установщик Windows панель узнаёт по ISO (data-os) и сама выбирает тип, пока пользователь не выбрал его руками.
const guessOs = () => {
const guess = picked('iso_path')?.dataset.os || '';
const hint = $('[data-os-hint]', form);
if (form.dataset.osManual) return;
const radio = $(`input[name=os_type][value="${guess || 'linux'}"]`, form);
if (radio) radio.checked = true;
if (hint) hint.hidden = !guess;
};
form.addEventListener('input', (event) => { if (event.target.name === 'ci_user') event.target.dataset.autoLogin = event.target.value ? '0' : '1'; sync(); }); form.addEventListener('input', (event) => { if (event.target.name === 'ci_user') event.target.dataset.autoLogin = event.target.value ? '0' : '1'; sync(); });
form.addEventListener('change', sync); form.addEventListener('change', (event) => {
if (event.target.name === 'os_type') { form.dataset.osManual = '1'; const hint = $('[data-os-hint]', form); if (hint) hint.hidden = true; }
if (event.target.name === 'iso_path') guessOs();
sync();
});
// Поиск по образам, когда их много.
$$('[data-image-picker]', form).forEach((picker) => {
const search = $('[data-image-search]', picker);
if (!search) return;
search.addEventListener('input', () => {
const query = search.value.trim().toLowerCase();
let shown = 0;
$$('.image-option', picker).forEach((item) => { item.hidden = Boolean(query) && !item.dataset.search.includes(query); shown += item.hidden ? 0 : 1; });
$('[data-image-empty]', picker).hidden = shown > 0;
});
});
guessOs();
sync(); sync();
} }
+26
Просмотреть файл
@@ -0,0 +1,26 @@
{% from "_ui.html" import icon %}
{#- Выбор образа карточками. Ожидает: picker_name (имя поля), picker_items (list_iso_files или list_disk_image_files),
picker_selected (путь выбранного), picker_icon. Поиск появляется, когда образов больше шести. -#}
<div class="image-picker" data-image-picker>
{% if picker_items|length > 6 %}
<input class="input" type="search" placeholder="Найти образ по названию" aria-label="Найти образ" data-image-search>
{% endif %}
<div class="image-list" role="radiogroup">
{% for item in picker_items %}
{% set title = item.title or item.name %}
<label class="image-option" data-search="{{ (title ~ ' ' ~ item.name ~ ' ' ~ (item.system or ''))|lower }}">
<input type="radio" name="{{ picker_name }}" value="{{ item.path }}" data-label="{{ title }}" data-os="{{ item.os_type or '' }}" data-login="{{ item.login or '' }}" {% if picker_selected == item.path or (not picker_selected and loop.first) %}checked{% endif %}>
<span class="image-option-body">
<span class="list-icon brand">{{ icon(picker_icon) }}</span>
<span class="grow">
<span class="image-title">{{ title }}</span>
<span class="image-meta">{% if item.title %}{{ item.name }} · {% endif %}{{ item.size_label or item.size }}{% if item.date_label %} · загружен {{ item.date_label }}{% endif %}</span>
</span>
{% if item.system %}<span class="tag">{{ item.system }}</span>{% endif %}
<span class="option-check"></span>
</span>
</label>
{% endfor %}
</div>
<p class="subtle small" data-image-empty hidden>Образов с таким названием нет.</p>
</div>
+2 -2
Просмотреть файл
@@ -101,9 +101,9 @@
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить {{ vm.name }} принудительно?" data-confirm-button="Выключить" data-toast="Машина {{ vm.name }} выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}Выключить принудительно</button></form> <form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить {{ vm.name }} принудительно?" data-confirm-button="Выключить" data-toast="Машина {{ vm.name }} выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}Выключить принудительно</button></form>
{% elif vm.state != 'paused' %} {% elif vm.state != 'paused' %}
<form method="post" action="/vm/{{ vm.name }}/start" data-toast="Машина {{ vm.name }} запускается"><button class="menu-item">{{ icon('play') }}Запустить</button></form> <form method="post" action="/vm/{{ vm.name }}/start" data-toast="Машина {{ vm.name }} запускается"><button class="menu-item">{{ icon('play') }}Запустить</button></form>
<div class="menu-sep"></div>
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}Удалить машину</button></form>
{% endif %} {% endif %}
<div class="menu-sep"></div>
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="{% if vm.state != 'shut off' %}Машина {{ vm.name }} работает: она будет сразу выключена, как при отключении питания, а затем удалена вместе со всеми дисками.{% else %}Машина {{ vm.name }} будет удалена вместе со всеми дисками.{% endif %} Отменить это нельзя. Резервные копии машины останутся, из них её можно восстановить." data-confirm-title="Удалить {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}Удалить машину</button></form>
</div> </div>
</details> </details>
</div> </div>
+1 -1
Просмотреть файл
@@ -38,7 +38,7 @@
<ul> <ul>
<li>Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.</li> <li>Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.</li>
<li>Если курсор «застрял» внутри экрана, нажмите <code>Ctrl + Alt</code>.</li> <li>Если курсор «застрял» внутри экрана, нажмите <code>Ctrl + Alt</code>.</li>
<li>После установки системы извлеките установочный диск на странице машины → «Диски и загрузка».</li> <li>После установки машина перезагрузится уже с диска: он стоит первым в порядке загрузки. Установочный образ можно извлечь на странице машины → «Диски и загрузка».</li>
</ul> </ul>
</div></article> </div></article>
+8 -1
Просмотреть файл
@@ -25,10 +25,17 @@
<div class="progress lg {{ 'success' if operation.status == 'success' else 'danger' if operation.status == 'error' else '' }}" data-op="progress" style="height:10px"><span data-op="bar" style="width:{{ operation.progress|default(0) }}%"></span></div> <div class="progress lg {{ 'success' if operation.status == 'success' else 'danger' if operation.status == 'error' else '' }}" data-op="progress" style="height:10px"><span data-op="bar" style="width:{{ operation.progress|default(0) }}%"></span></div>
<p class="muted" data-op="message">{{ operation.message }}</p> <p class="muted" data-op="message">{{ operation.message }}</p>
{% if operation.vm_name %} {% if operation.vm_name %}
<div class="row" data-op-done {% if operation.status != 'success' %}hidden{% endif %}> <div class="row" data-op-done="success" {% if operation.status != 'success' %}hidden{% endif %}>
<a class="btn btn-primary" href="/vm/{{ operation.vm_name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран машины</a> <a class="btn btn-primary" href="/vm/{{ operation.vm_name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран машины</a>
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a> <a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
</div> </div>
<div class="row" data-op-done="error" {% if operation.type == 'vm_create' %}data-op-reload{% endif %} {% if operation.status != 'error' %}hidden{% endif %}>
{% if operation.type == 'vm_create' and not vm_present %}
<a class="btn btn-primary" href="{{ retry_url }}">{{ icon('rotate-cw') }}Создать заново</a>
{% elif vm_present %}
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
{% endif %}
</div>
{% endif %} {% endif %}
</div> </div>
</article> </article>
+7 -10
Просмотреть файл
@@ -16,7 +16,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<form method="post" action="/vm/create" class="grid grid-main" data-wizard data-has-isos="{{ '1' if isos else '0' }}" data-has-disks="{{ '1' if disk_images else '0' }}"> <form method="post" action="/vm/create" class="grid grid-main" data-wizard {% if form.name %}data-os-manual="1"{% endif %} data-has-isos="{{ '1' if isos else '0' }}" data-has-disks="{{ '1' if disk_images else '0' }}">
<div class="stack"> <div class="stack">
<article class="card step-card"> <article class="card step-card">
<div class="step-head"><span class="step-num">1</span><div><h2>Название и система</h2><p>Как назовём машину и что на неё установить</p></div></div> <div class="step-head"><span class="step-num">1</span><div><h2>Название и система</h2><p>Как назовём машину и что на неё установить</p></div></div>
@@ -47,12 +47,10 @@
</div> </div>
<div class="field" data-show-when="source_type=iso"> <div class="field" data-show-when="source_type=iso">
<label for="iso_path">Установочный образ</label> <span class="label">Установочный образ</span>
{% if isos %} {% if isos %}
<select class="select" id="iso_path" name="iso_path"> {% with picker_name = 'iso_path', picker_items = isos, picker_selected = form.iso_path, picker_icon = 'disc-3' %}{% include "_image_picker.html" %}{% endwith %}
{% for iso in isos %}<option value="{{ iso.path }}" data-label="{{ iso.name }}" {% if form.iso_path == iso.path %}selected{% endif %}>{{ iso.name }} · {{ iso.size }}</option>{% endfor %} <span class="hint">Образы хранятся на сервере отдельно от машин, один образ можно ставить сколько угодно раз. Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
</select>
<span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
{% endif %} {% endif %}
</div> </div>
<div class="field" data-show-when="source_type=iso"> <div class="field" data-show-when="source_type=iso">
@@ -65,14 +63,13 @@
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
<span class="hint" data-os-hint hidden>{{ icon('sparkles', 'icon-sm') }} Это установщик Windows, тип выбран сам. Если ошиблись, выберите другой.</span>
<span class="hint">От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.</span> <span class="hint">От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.</span>
</div> </div>
<div class="field" data-show-when="source_type=disk_image"> <div class="field" data-show-when="source_type=disk_image">
<label for="disk_image_path">Образ системы</label> <span class="label">Образ системы</span>
{% if disk_images %} {% if disk_images %}
<select class="select" id="disk_image_path" name="disk_image_path"> {% with picker_name = 'disk_image_path', picker_items = disk_images, picker_selected = form.disk_image_path, picker_icon = 'hard-drive' %}{% include "_image_picker.html" %}{% endwith %}
{% for image in disk_images %}<option value="{{ image.path }}" data-label="{{ image.title or image.name }}" data-login="{{ image.login }}" {% if form.disk_image_path == image.path %}selected{% endif %}>{{ image.title or image.name }} · {{ image.size }}</option>{% endfor %}
</select>
<span class="hint">Образ будет скопирован — оригинал останется для других машин.</span> <span class="hint">Образ будет скопирован — оригинал останется для других машин.</span>
{% endif %} {% endif %}
</div> </div>
+1 -2
Просмотреть файл
@@ -33,9 +33,8 @@
{% if s.running %} {% if s.running %}
<form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}<span>Перезагрузить<small>Мягкая перезагрузка системы</small></span></button></form> <form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}<span>Перезагрузить<small>Мягкая перезагрузка системы</small></span></button></form>
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить принудительно?" data-confirm-button="Выключить" data-toast="Машина выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}<span>Выключить принудительно<small>Если система зависла</small></span></button></form> <form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить принудительно?" data-confirm-button="Выключить" data-toast="Машина выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}<span>Выключить принудительно<small>Если система зависла</small></span></button></form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина {{ vm.name }} и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить машину?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}<span>Удалить машину<small>Вместе с дисками</small></span></button></form>
{% endif %} {% endif %}
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="{% if vm.state != 'shut off' %}Машина {{ vm.name }} работает: она будет сразу выключена, как при отключении питания, а затем удалена вместе со всеми дисками.{% else %}Машина {{ vm.name }} будет удалена вместе со всеми дисками.{% endif %} Отменить это нельзя. Резервные копии машины останутся, из них её можно восстановить." data-confirm-title="Удалить машину {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}<span>Удалить машину<small>Вместе с дисками</small></span></button></form>
</div> </div>
</details> </details>
</div> </div>