Выбор образа при создании машины карточками: название системы, размер, дата загрузки и поиск, когда образов много

Этот коммит содержится в:
2026-09-27 22:42:23 +09:00
родитель c04e5285e7
Коммит c19d8db6e7
10 изменённых файлов: 117 добавлений и 21 удалений
Двоичные данные
Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 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
+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>