2 Коммитов
Автор 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
14 изменённых файлов: 139 добавлений и 31 удалений
+2 -2
Просмотреть файл
@@ -2,7 +2,7 @@
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка. **Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
Текущая версия: **0.12.7** (файл `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.7-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).
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.12.7 0.12.8
+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.7-ubuntu-26.04-amd64.iso dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso
dist/virtuality-0.12.7-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.7** (выбирается сам через 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.7-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.7-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.7-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.7-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.7-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

+1 -1
Просмотреть файл
@@ -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" data-os="" 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):
+12 -1
Просмотреть файл
@@ -188,5 +188,16 @@ def test_windows_iso_preselects_windows_type(logged_in, data_dirs):
fake_iso(data_dirs["iso"] / "ubuntu.iso", "Ubuntu-Server 26.04 LTS amd64") 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" 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 html = logged_in.get("/vm/create?iso=19044.4046---Win10-IoT-LTSC.iso").text
assert 'data-os="windows10" selected' in html and 'data-label="ubuntu.iso" data-os=""' in html 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 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
+12
Просмотреть файл
@@ -1,5 +1,17 @@
{ {
"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", "version": "0.12.7",
"date": "2026-09-27", "date": "2026-09-27",
+8 -2
Просмотреть файл
@@ -577,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, "os_type": catalog.guess_os_type(iso_volume_label(item), item.name)}) 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
@@ -635,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
+19
Просмотреть файл
@@ -88,6 +88,25 @@ def guess_os_type(volume_label: str, filename: str) -> str:
return "windows10" if WINDOWS10_RE.search(text) else "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)
+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; }
+16 -8
Просмотреть файл
@@ -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')} ГБ`);
@@ -480,9 +479,7 @@
} }
// Установщик Windows панель узнаёт по ISO (data-os) и сама выбирает тип, пока пользователь не выбрал его руками. // Установщик Windows панель узнаёт по ISO (data-os) и сама выбирает тип, пока пользователь не выбрал его руками.
const guessOs = () => { const guessOs = () => {
const select = field('iso_path'); const guess = picked('iso_path')?.dataset.os || '';
const option = select && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
const guess = option?.dataset.os || '';
const hint = $('[data-os-hint]', form); const hint = $('[data-os-hint]', form);
if (form.dataset.osManual) return; if (form.dataset.osManual) return;
const radio = $(`input[name=os_type][value="${guess || 'linux'}"]`, form); const radio = $(`input[name=os_type][value="${guess || 'linux'}"]`, form);
@@ -495,6 +492,17 @@
if (event.target.name === 'iso_path') guessOs(); if (event.target.name === 'iso_path') guessOs();
sync(); 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(); 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>
+5 -9
Просмотреть файл
@@ -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 }}" data-os="{{ iso.os_type }}" {% 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">
@@ -69,11 +67,9 @@
<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>