diff --git a/docs/screenshots/create.png b/docs/screenshots/create.png index 982dee4..f677a41 100644 Binary files a/docs/screenshots/create.png and b/docs/screenshots/create.png differ diff --git a/tests/test_app.py b/tests/test_app.py index 9af5b43..3331db8 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -102,7 +102,7 @@ def test_dashboard_uses_plain_language(logged_in): def test_vm_create_preselects_image(logged_in, data_dirs): (data_dirs["iso"] / "debian.iso").write_bytes(b"iso") 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): diff --git a/tests/test_quickvm.py b/tests/test_quickvm.py index 8d6a8c2..c802641 100644 --- a/tests/test_quickvm.py +++ b/tests/test_quickvm.py @@ -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") 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" 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 + + +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 'Windows 10' in html and 'Ubuntu' 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 diff --git a/web/app.py b/web/app.py index 18a4433..2043d4b 100644 --- a/web/app.py +++ b/web/app.py @@ -577,7 +577,12 @@ def list_iso_files() -> list[dict[str, str]]: except OSError: size_mb = 0 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 @@ -635,7 +640,8 @@ def list_disk_image_files() -> list[dict[str, str]]: size_gb = 0 updated = "unknown" 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 diff --git a/web/catalog.py b/web/catalog.py index 5fe9272..5714bc6 100644 --- a/web/catalog.py +++ b/web/catalog.py @@ -88,6 +88,25 @@ def guess_os_type(volume_label: str, filename: str) -> str: 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: return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None) diff --git a/web/presenters.py b/web/presenters.py index df53e55..21f6a82 100644 --- a/web/presenters.py +++ b/web/presenters.py @@ -242,6 +242,17 @@ def port_service(port: int) -> str: 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: value = float(value or 0) for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): diff --git a/web/static/app.css b/web/static/app.css index 6be33fd..e1b336d 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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-title { font-weight: 650; font-size: 14px; } .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 — видны всегда). */ .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; } diff --git a/web/static/panel.js b/web/static/panel.js index e7a3a10..d0da91e 100644 --- a/web/static/panel.js +++ b/web/static/panel.js @@ -435,7 +435,8 @@ 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 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() { const source = value('source_type'); @@ -454,9 +455,7 @@ // Default login of the chosen cloud image (ubuntu, debian, …) until the user types a name. const login = field('ci_user'); if (login && login.dataset.autoLogin === '1') { - const image = field('disk_image_path'); - const option = image && image.selectedIndex >= 0 ? image.options[image.selectedIndex] : null; - login.value = option?.dataset.login || 'admin'; + login.value = picked('disk_image_path')?.dataset.login || 'admin'; } const preset = value('preset'); const custom = preset === 'custom'; @@ -469,7 +468,7 @@ $$('[data-custom-resources] input', form).forEach((input) => { input.readOnly = !custom; }); const memory = Number(value('memory')) || 0; 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('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`); setSummary('disk', `${value('disk_size')} ГБ`); @@ -480,9 +479,7 @@ } // Установщик Windows панель узнаёт по ISO (data-os) и сама выбирает тип, пока пользователь не выбрал его руками. const guessOs = () => { - const select = field('iso_path'); - const option = select && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null; - const guess = option?.dataset.os || ''; + 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); @@ -495,6 +492,17 @@ 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(); } diff --git a/web/templates/_image_picker.html b/web/templates/_image_picker.html new file mode 100644 index 0000000..b336a9f --- /dev/null +++ b/web/templates/_image_picker.html @@ -0,0 +1,26 @@ +{% from "_ui.html" import icon %} +{#- Выбор образа карточками. Ожидает: picker_name (имя поля), picker_items (list_iso_files или list_disk_image_files), + picker_selected (путь выбранного), picker_icon. Поиск появляется, когда образов больше шести. -#} +
+ {% if picker_items|length > 6 %} + + {% endif %} +
+ {% for item in picker_items %} + {% set title = item.title or item.name %} + + {% endfor %} +
+ +
diff --git a/web/templates/vm_create.html b/web/templates/vm_create.html index 52b44d6..3ba8901 100644 --- a/web/templates/vm_create.html +++ b/web/templates/vm_create.html @@ -47,12 +47,10 @@
- + Установочный образ {% if isos %} - - Нужен другой? Загрузите образ. + {% with picker_name = 'iso_path', picker_items = isos, picker_selected = form.iso_path, picker_icon = 'disc-3' %}{% include "_image_picker.html" %}{% endwith %} + Образы хранятся на сервере отдельно от машин, один образ можно ставить сколько угодно раз. Нужен другой? Загрузите образ. {% endif %}
@@ -69,11 +67,9 @@ От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.
- + Образ системы {% if disk_images %} - + {% 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 %} Образ будет скопирован — оригинал останется для других машин. {% endif %}