Сравнить коммиты
4
Коммитов
v0.12.6
...
a840e0c828
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
a840e0c828 | ||
|
|
c19d8db6e7 | ||
|
|
c04e5285e7 | ||
|
|
5d02cc81ff |
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
||||||
|
|
||||||
Текущая версия: **0.12.6** (файл `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.6-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,7 +166,7 @@ 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 остаются).
|
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается, установочные ISO остаются).
|
||||||
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
|
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.12.6
|
0.12.8
|
||||||
|
|||||||
@@ -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.6-ubuntu-26.04-amd64.iso
|
dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso
|
||||||
dist/virtuality-0.12.6-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.6** (выбирается сам через 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.6-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.6-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.6-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.6-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.6-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 |
@@ -102,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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -153,3 +156,48 @@ def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch):
|
|||||||
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
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
{
|
{
|
||||||
"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",
|
"version": "0.12.6",
|
||||||
"date": "2026-09-27",
|
"date": "2026-09-27",
|
||||||
|
|||||||
+22
-2
@@ -553,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 = []
|
||||||
@@ -564,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
|
||||||
|
|
||||||
|
|
||||||
@@ -622,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
|
||||||
|
|
||||||
|
|
||||||
@@ -1819,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 ("Б", "КБ", "МБ", "ГБ", "ТБ"):
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user