Новая страница /vm/{name}/snapshots и вкладка «Снимки» на странице машины:
список внутренних снимков libvirt с описанием, датой, состоянием и меткой
«Текущий», создание снимка с автоматическим именем snap-ГГГГММДД-ЧЧММ,
возврат к снимку и удаление. Создание и возврат идут как фоновые задачи
с журналом; страница сама обновляется, когда задача завершится.
Ограничения и ошибки объясняются по-русски: не больше 8 снимков на машину,
только диски qcow2, предупреждение для машин с UEFI; известные ошибки libvirt
переводятся, а исходный текст показывается рядом. Возврат повторяется
с --force, если libvirt этого требует.
Модуль web/features/snapshots.py, парсеры с тестами на образцах вывода virsh,
fake_run_cmd в тестах расширен командами snapshot-*, demo-сервер показывает
снимки и идущую задачу. В спрайт добавлены иконки camera и history.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
239 строки
17 KiB
HTML
239 строки
17 KiB
HTML
{% extends "base.html" %}
|
|
{% from "_ui.html" import icon, badge, vm_badge, alert %}
|
|
{% set page_title = vm.name %}
|
|
{% set s = (vm.info.state or ('running' if 'running' in vm.dominfo|lower else 'shut off'))|vm_state %}
|
|
{% set rs = resource_settings|default({}) %}
|
|
{% set has_ip = vm.ip and vm.ip not in ('—', 'not available') %}
|
|
|
|
{% block page_header %}
|
|
<div class="page-header" data-live-vm="{{ vm.name }}" data-running="{{ '1' if s.running else '0' }}" data-reload-on-change>
|
|
<div>
|
|
<div class="eyebrow"><a href="/">{{ icon('arrow-left', 'icon-sm') }} Все машины</a></div>
|
|
<div class="title-row">
|
|
<span class="vm-avatar {{ 'on' if s.running else '' }}" style="width:48px;height:48px">{{ icon('monitor', 'icon-lg') }}<span class="status-dot {{ s.tone }}" data-live="dot"></span></span>
|
|
<div>
|
|
<div class="row"><h1>{{ vm.name }}</h1>{{ vm_badge(vm.info.state or s.raw, 'lg') }}</div>
|
|
<p class="subtle mt-1">{{ vm.info.vcpus }} {{ plural(vm.info.vcpus, 'ядро', 'ядра', 'ядер') }} · {{ vm.info.memory_mb|format_mb }} памяти{% if has_ip %} · <span data-live="ip">{{ vm.ip }}</span>{% endif %}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="page-actions">
|
|
{% if s.running %}
|
|
<a class="btn btn-primary" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран</a>
|
|
<form method="post" action="/vm/{{ vm.name }}/shutdown" class="inline-form" data-toast="Машина выключается — это может занять до минуты"><button class="btn">{{ icon('power') }}Выключить</button></form>
|
|
{% else %}
|
|
<form method="post" action="/vm/{{ vm.name }}/start" class="inline-form" data-toast="Машина запускается"><button class="btn btn-primary">{{ icon('play') }}Запустить</button></form>
|
|
{% endif %}
|
|
<details class="menu">
|
|
<summary class="btn btn-icon" aria-label="Другие действия">{{ icon('ellipsis') }}</summary>
|
|
<div class="menu-list">
|
|
{% if s.running %}
|
|
<form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}<span>Перезагрузить<small>Мягкая перезагрузка системы</small></span></button></form>
|
|
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить принудительно?" data-confirm-button="Выключить" data-toast="Машина выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}<span>Выключить принудительно<small>Если система зависла</small></span></button></form>
|
|
{% else %}
|
|
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина {{ vm.name }} и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить машину?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}<span>Удалить машину<small>Вместе с дисками</small></span></button></form>
|
|
{% endif %}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block alerts %}
|
|
{% for key in ('resource', 'iso', 'boot') %}
|
|
{% set msg = {'resource': resource_message, 'iso': iso_message, 'boot': boot_message}[key] %}
|
|
{% set err = {'resource': resource_error, 'iso': iso_error, 'boot': boot_error}[key] %}
|
|
{% if msg %}{{ alert('success', msg) }}{% endif %}
|
|
{% if err %}{{ alert('danger', err) }}{% endif %}
|
|
{% endfor %}
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<nav class="tabs" data-tabs role="tablist">
|
|
<button type="button" data-tab="overview" role="tab">{{ icon('layout-dashboard', 'icon-sm') }}Обзор</button>
|
|
<button type="button" data-tab="resources" role="tab">{{ icon('cpu', 'icon-sm') }}Ресурсы</button>
|
|
<button type="button" data-tab="storage" role="tab">{{ icon('hard-drive', 'icon-sm') }}Диски и загрузка</button>
|
|
<a href="/vm/{{ vm.name }}/snapshots">{{ icon('camera', 'icon-sm') }}Снимки</a>
|
|
<button type="button" data-tab="expert" role="tab">{{ icon('terminal', 'icon-sm') }}Для специалистов</button>
|
|
</nav>
|
|
|
|
<section data-tab-panel="overview" class="grid grid-main">
|
|
<div class="stack">
|
|
<article class="card">
|
|
<div class="card-header"><h2>Сводка</h2></div>
|
|
<div class="card-body">
|
|
<div class="kv">
|
|
<div><span>Состояние</span><strong data-live-vm="{{ vm.name }}">{{ vm_badge(vm.info.state or s.raw) }}</strong></div>
|
|
<div><span>IP-адрес</span><strong>{% if has_ip %}{{ vm.ip }} <button type="button" class="btn btn-ghost btn-icon btn-sm" data-copy="{{ vm.ip }}" title="Скопировать" style="height:22px;width:22px;vertical-align:middle">{{ icon('copy', 'icon-sm') }}</button>{% else %}<span class="subtle">{{ 'Определяется…' if s.running else 'Нет — машина выключена' }}</span>{% endif %}</strong></div>
|
|
<div><span>Процессор</span><strong>{{ vm.info.vcpus }} {{ plural(vm.info.vcpus, 'ядро', 'ядра', 'ядер') }}</strong></div>
|
|
<div><span>Память</span><strong>{{ vm.info.memory_mb|format_mb }}</strong></div>
|
|
<div><span>Архитектура</span><strong>{{ rs.arch|default('—') }}</strong></div>
|
|
<div><span>Сеть</span><strong>{{ (vm.interface_list[0].source if vm.interface_list else '—')|replace('virtuality-nat', 'Автоматически (NAT)') }}</strong></div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="card">
|
|
<div class="card-header"><div><h2>Как подключиться</h2><p>Выберите удобный способ</p></div></div>
|
|
<div class="list">
|
|
<div class="list-item">
|
|
<span class="list-icon brand">{{ icon('monitor') }}</span>
|
|
<div class="grow"><div class="title">Экран в браузере</div><div class="meta">Как монитор, подключённый к машине. Подходит для установки системы.</div></div>
|
|
{% if s.running %}<a class="btn btn-sm" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">Открыть{{ icon('external-link', 'icon-sm') }}</a>{% else %}<span class="subtle small">Запустите машину</span>{% endif %}
|
|
</div>
|
|
<div class="list-item">
|
|
<span class="list-icon">{{ icon('terminal') }}</span>
|
|
<div class="grow"><div class="title">SSH с этого сервера</div><div class="meta">{% if has_ip %}<code>ssh пользователь@{{ vm.ip }}</code>{% else %}Станет доступно, когда машина получит IP-адрес{% endif %}</div></div>
|
|
</div>
|
|
<div class="list-item">
|
|
<span class="list-icon">{{ icon('globe') }}</span>
|
|
<div class="grow"><div class="title">Доступ из интернета</div><div class="meta">Откройте порт машины наружу: сайт, SSH, удалённый рабочий стол</div></div>
|
|
<a class="btn btn-sm" href="/network">Настроить</a>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<div class="stack">
|
|
<article class="card">
|
|
<div class="card-body stack-sm">
|
|
<form method="post" action="/vm/{{ vm.name }}/{{ 'autostart-disable' if vm.autostart_enabled else 'autostart' }}" class="switch-row" data-no-busy data-toast="{{ 'Автозапуск выключен' if vm.autostart_enabled else 'Машина будет запускаться вместе с сервером' }}">
|
|
<div class="switch-text"><strong>Автозапуск</strong><span>Запускать машину вместе с сервером</span></div>
|
|
<button type="submit" class="switch" role="switch" aria-checked="{{ 'true' if vm.autostart_enabled else 'false' }}" aria-label="Автозапуск"></button>
|
|
</form>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="card">
|
|
<div class="card-header"><h2>Установочный диск</h2></div>
|
|
<div class="card-body stack-sm">
|
|
{% if current_iso %}
|
|
<div class="upload-file"><span class="list-icon brand">{{ icon('disc-3') }}</span><div class="grow"><strong class="truncate" style="display:block">{{ current_iso.split('/')[-1] }}</strong><span class="subtle small">Вставлен в привод</span></div></div>
|
|
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" data-confirm="Образ будет извлечён из привода машины." data-confirm-title="Извлечь диск?" data-confirm-button="Извлечь" data-confirm-tone="brand"><button class="btn btn-sm btn-block">{{ icon('x', 'icon-sm') }}Извлечь</button></form>
|
|
{% else %}
|
|
<p class="subtle small">Привод пуст. Вставьте образ, чтобы установить систему или загрузиться с диска восстановления.</p>
|
|
<a class="btn btn-sm btn-block" href="#storage" onclick="document.querySelector('[data-tab=storage]').click()">{{ icon('disc-3', 'icon-sm') }}Вставить образ</a>
|
|
{% endif %}
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<section data-tab-panel="resources" hidden>
|
|
<article class="card" style="max-width:720px">
|
|
<div class="card-header"><div><h2>Процессор и память</h2><p>Сколько ресурсов сервера получает машина</p></div></div>
|
|
<form method="post" action="/vm/{{ vm.name }}/resources" data-toast="Настройки сохранены">
|
|
<div class="card-body form-grid">
|
|
{% if not rs.is_shutoff %}
|
|
{{ alert('info', 'Выключите машину, чтобы изменить процессор и память.') }}
|
|
{% endif %}
|
|
<div class="form-row">
|
|
<div class="field">
|
|
<label for="vcpus">Ядра процессора</label>
|
|
<input class="input" id="vcpus" type="number" name="vcpus" min="1" max="128" value="{{ rs.vcpus|default(1) }}" {% if not rs.is_shutoff %}disabled{% endif %} required>
|
|
</div>
|
|
<div class="field">
|
|
<label for="memory_mb">Память</label>
|
|
<div class="input-group"><input class="input" id="memory_mb" type="number" name="memory_mb" min="512" max="262144" step="512" value="{{ rs.memory_mb|default(1024) }}" {% if not rs.is_shutoff %}disabled{% endif %} required><span class="addon">МБ</span></div>
|
|
<span class="hint">1024 МБ = 1 ГБ</span>
|
|
</div>
|
|
</div>
|
|
<details class="advanced">
|
|
<summary>{{ icon('wrench', 'icon-sm') }}Архитектура процессора<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
|
|
<div class="advanced-body field">
|
|
<select class="select" name="guest_arch" {% if not rs.is_shutoff %}disabled{% endif %}>
|
|
<option value="keep">Не менять — {{ rs.arch|default('неизвестно') }}</option>
|
|
<option value="x86_64">x86_64 — обычные ПК и серверы</option>
|
|
<option value="aarch64">ARM64 — образы для Raspberry Pi и ARM-серверов</option>
|
|
</select>
|
|
<span class="hint">Меняйте, только если точно знаете, зачем: система внутри может перестать загружаться.</span>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
<div class="card-footer"><button type="submit" class="btn btn-primary" {% if not rs.is_shutoff %}disabled{% endif %}>Сохранить</button></div>
|
|
</form>
|
|
</article>
|
|
</section>
|
|
|
|
<section data-tab-panel="storage" class="grid grid-2" hidden>
|
|
<article class="card">
|
|
<div class="card-header"><div><h2>Порядок загрузки</h2><p>С чего машина пытается загрузиться в первую очередь</p></div></div>
|
|
<form method="post" action="/vm/{{ vm.name }}/boot-order" data-toast="Порядок загрузки сохранён">
|
|
<input type="hidden" name="boot_order" value="{{ current_boot_order|default('auto') }}" data-boot-value>
|
|
<div class="list" data-boot-list data-current="{{ current_boot_order|default('auto') }}">
|
|
{% for dev, title, hint, ic in [('hd', 'Жёсткий диск', 'Установленная система', 'hard-drive'), ('cdrom', 'Установочный диск', 'ISO-образ в приводе', 'disc-3'), ('network', 'Сеть (PXE)', 'Загрузка по сети', 'network')] %}
|
|
<div class="list-item" data-device="{{ dev }}">
|
|
<span class="list-icon"><b data-position>{{ loop.index }}</b></span>
|
|
{{ icon(ic) }}
|
|
<div class="grow"><div class="title">{{ title }}</div><div class="meta">{{ hint }}</div></div>
|
|
<button type="button" class="btn btn-ghost btn-icon btn-sm" data-move="up" aria-label="Выше"><svg class="icon icon-sm" style="transform:rotate(180deg)"><use href="/static/icons.svg#i-chevron-down"></use></svg></button>
|
|
<button type="button" class="btn btn-ghost btn-icon btn-sm" data-move="down" aria-label="Ниже">{{ icon('chevron-down', 'icon-sm') }}</button>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
<div class="card-footer"><span class="subtle small" style="margin-right:auto">Применится при следующем запуске</span><button type="submit" class="btn btn-primary btn-sm">Сохранить порядок</button></div>
|
|
</form>
|
|
</article>
|
|
|
|
<article class="card">
|
|
<div class="card-header"><div><h2>Привод CD/DVD</h2><p>Вставьте образ, чтобы установить систему</p></div></div>
|
|
<div class="card-body stack-sm">
|
|
{% if isos %}
|
|
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="stack-sm" data-toast="Образ вставлен в привод">
|
|
<div class="field">
|
|
<label for="iso_path">Образ диска</label>
|
|
<select class="select" id="iso_path" name="iso_path" required>
|
|
{% for iso in isos %}<option value="{{ iso.path }}" {% if current_iso == iso.path %}selected{% endif %}>{{ iso.name }} · {{ iso.size }}</option>{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="row">
|
|
<button class="btn btn-primary btn-sm" type="submit">{{ icon('disc-3', 'icon-sm') }}Вставить</button>
|
|
{% if current_iso %}<button class="btn btn-sm" type="submit" form="iso-eject">Извлечь текущий</button>{% endif %}
|
|
</div>
|
|
</form>
|
|
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" id="iso-eject" data-toast="Образ извлечён"></form>
|
|
{% else %}
|
|
<p class="subtle">Образов пока нет.</p>
|
|
<a class="btn btn-sm" href="/iso">{{ icon('cloud-upload', 'icon-sm') }}Загрузить образ</a>
|
|
{% endif %}
|
|
</div>
|
|
</article>
|
|
|
|
<article class="card">
|
|
<div class="card-header"><h2>Диски</h2></div>
|
|
<div class="list">
|
|
{% for disk in vm.disk_list %}
|
|
<div class="list-item"><span class="list-icon">{{ icon('disc-3' if disk.is_cdrom else 'hard-drive') }}</span><div class="grow"><div class="title truncate">{{ disk.name }}</div><div class="meta">{{ disk.label }} · {{ disk.target }}</div></div></div>
|
|
{% else %}
|
|
<div class="list-item subtle">Дисков не найдено</div>
|
|
{% endfor %}
|
|
</div>
|
|
</article>
|
|
|
|
<article class="card">
|
|
<div class="card-header"><h2>Сетевые карты</h2></div>
|
|
<div class="list">
|
|
{% for nic in vm.interface_list %}
|
|
<div class="list-item"><span class="list-icon">{{ icon('network') }}</span><div class="grow"><div class="title">{{ nic.source|replace('virtuality-nat', 'Автоматическая сеть (NAT)') }}</div><div class="meta mono">{{ nic.mac }} · {{ nic.model }}</div></div></div>
|
|
{% else %}
|
|
<div class="list-item subtle">Сетевых карт не найдено</div>
|
|
{% endfor %}
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
<section data-tab-panel="expert" class="stack" hidden>
|
|
<div class="kv">
|
|
<div><span>VNC-дисплей</span><strong class="mono">{{ vm.vnc }}</strong></div>
|
|
<div><span>Адрес сервера</span><strong class="mono">{{ host_ip }}</strong></div>
|
|
<div><span>UUID</span><strong class="mono small">{{ vm.info.uuid or '—' }}</strong></div>
|
|
</div>
|
|
{% for title, cmd, body in [('Информация о машине', 'virsh dominfo ' ~ vm.name, vm.dominfo), ('Диски', 'virsh domblklist ' ~ vm.name ~ ' --details', vm.disks), ('Сетевые интерфейсы', 'virsh domiflist ' ~ vm.name, vm.interfaces)] %}
|
|
<div>
|
|
<div class="terminal-head"><i></i><i></i><i></i><span style="margin-left:6px">{{ title }} — {{ cmd }}</span></div>
|
|
<pre class="terminal">{{ body or '—' }}</pre>
|
|
</div>
|
|
{% endfor %}
|
|
</section>
|
|
{% endblock %}
|