Files
virtuality/web/templates/vm_detail.html
T
Claude 34684634af Повседневные операции с машиной были добавлены: диск, клон, пауза, заметки, нагрузка
Модуль web/features/vmops.py (router подключён двумя строками в app.py):

- Увеличение диска на вкладке «Диски и загрузка»: размер из qemu-img info,
  только рост, проверка свободного места; выключенная машина — qemu-img resize,
  работающая — virsh blockresize. Диски вне /var/lib/virtuality не трогаются.
- Клонирование: страница /vm/{name}/clone с проверками (машина выключена,
  хватает места, имя свободно), фоновая операция virt-clone с явными --file
  в хранилище образов, прогресс по росту файлов копии, pool-refresh после.
- Пауза, продолжение, спящий режим (managedsave как операция) и удаление
  сохранённого состояния — /vm/{name}/ops/{action}; пункты меню на обзоре
  и странице машины, кнопка «Продолжить» для приостановленной машины.
- Заметки: название и описание через virsh desc (--new-desc=ТЕКСТ одним
  аргументом, чтобы текст вида «--edit» не стал опцией); название видно
  под именем машины на обзоре (virsh list --all --title, парсер в presenters).
- Живая нагрузка: /live/stats по virsh domstats для всех активных машин
  одним вызовом, CPU % по разнице cpu.time между опросами; шкалы в строках
  обзора и карточка «Нагрузка сейчас» на странице машины (panel.js, 5 с).

Тесты для всех парсеров и маршрутов (tests/test_vmops.py), демо-данные
в dev_server.py (три машины в разных состояниях, живые шкалы), значки
notebook-pen и expand в спрайте.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:00:41 +00:00

234 строки
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>
{% elif s.raw == 'paused' %}
<form method="post" action="/vm/{{ vm.name }}/ops/resume" class="inline-form" data-toast="Машина продолжает работу"><button class="btn btn-primary">{{ icon('play') }}Продолжить</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">
{% set ops_name, ops_state, ops_next, ops_hints = vm.name, s.raw, '/vm/' ~ vm.name, true %}{% include "_vm_ops_menu.html" %}
{% 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 %}
{% include "_vm_ops_alerts.html" %}
{% 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>
<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">
{% include "_vm_ops_cards.html" %}
<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>
{% include "_vm_disks.html" %}
<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 %}