Сравнить коммиты
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
a840e0c828 | ||
|
|
c19d8db6e7 |
+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
|
||||||
|
|||||||
@@ -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 |
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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')} ГБ`);
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user