Merge branch 'feature/vmops' into claude/production-system-image-smcxyr

# Conflicts:
#	tests/conftest.py
#	tests/dev_server.py
#	web/app.py
#	web/static/app.css
#	web/static/icons.svg
Этот коммит содержится в:
Claude
2026-09-25 15:03:48 +00:00
родитель a43f96883e 34684634af
Коммит 2b7223f311
19 изменённых файлов: 1400 добавлений и 22 удалений
+27
Просмотреть файл
@@ -0,0 +1,27 @@
{#- Карточка «Диски» на вкладке «Диски и загрузка»: размеры и форма увеличения. Ожидает vm из vm_detail.html. -#}
{% from "_ui.html" import icon %}
{% set dk = vmops_disks(vm.name) %}
<article class="card">
<div class="card-header"><div><h2>Диски</h2><p>Диск можно увеличить, уменьшить — нельзя</p></div></div>
<div class="list">
{% for disk in dk.disks %}
<div class="list-item disk-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 }}{% if disk.size_text %} · {{ disk.size_text }}{% if disk.used_text %}, занято {{ disk.used_text }}{% endif %}{% endif %}</div>
</div>
{% if disk.resizable %}
<form method="post" action="/vm/{{ vm.name }}/disks/{{ disk.target }}/resize" class="disk-resize" data-confirm="Диск {{ disk.name }} станет больше. Уменьшить его обратно уже не получится." data-confirm-title="Увеличить диск?" data-confirm-button="Увеличить" data-confirm-tone="brand" data-busy="Увеличиваем…">
<label class="subtle small" for="disk-size-{{ disk.target }}">до</label>
<div class="input-group"><input class="input" id="disk-size-{{ disk.target }}" type="number" name="size_gb" min="{{ disk.min_gb }}" max="{{ dk.max_gb }}" step="1" value="{{ disk.min_gb }}" required aria-label="Новый размер диска {{ disk.name }} в гигабайтах"><span class="addon">ГБ</span></div>
<button class="btn btn-sm" type="submit">{{ icon('expand', 'icon-sm') }}Увеличить</button>
</form>
{% endif %}
</div>
{% else %}
<div class="list-item subtle">Дисков не найдено</div>
{% endfor %}
</div>
<div class="card-footer disk-footer"><p class="subtle small">Свободно на сервере: <strong>{{ dk.free_text }}</strong>. {{ dk.hint }}</p></div>
</article>
+6
Просмотреть файл
@@ -0,0 +1,6 @@
{#- Две маленькие шкалы (процессор, память) в строке машины на обзоре; panel.js заполняет их из /live/stats. -#}
{% from "_ui.html" import icon %}
<div class="vm-meters" data-live-stats="{{ vm.name }}" {% if not s.running %}hidden{% endif %}>
<span class="vm-meter" title="Процессор">{{ icon('cpu', 'icon-sm') }}<span class="meter" data-stat="cpu-meter"><span data-stat="cpu-bar" style="--value:0%"></span></span><b data-stat="cpu">—</b></span>
<span class="vm-meter" title="Память">{{ icon('memory-stick', 'icon-sm') }}<span class="meter" data-stat="mem-meter"><span data-stat="mem-bar" style="--value:0%"></span></span><b data-stat="mem">—</b></span>
</div>
+6
Просмотреть файл
@@ -0,0 +1,6 @@
{#- Сообщения после действий vmops (пауза, диски, заметки): читаются из адреса страницы. -#}
{% from "_ui.html" import alert %}
{% for key in ('ops', 'disk', 'notes') %}
{% if request.query_params.get(key ~ '_message') %}{{ alert('success', request.query_params.get(key ~ '_message')) }}{% endif %}
{% if request.query_params.get(key ~ '_error') %}{{ alert('danger', request.query_params.get(key ~ '_error')) }}{% endif %}
{% endfor %}
+43
Просмотреть файл
@@ -0,0 +1,43 @@
{#- Карточки на обзоре машины: нагрузка сейчас, спящий режим, заметки. Ожидает vm из vm_detail.html. -#}
{% from "_ui.html" import icon %}
{% set notes = vmops_notes(vm.name) %}
{% set ops_s = (vm.info.state or 'shut off')|vm_state %}
{% if ops_s.tone != 'neutral' %}
<article class="card" data-live-stats="{{ vm.name }}">
<div class="card-header"><div><h2>Нагрузка сейчас</h2><p>Обновляется каждые 5 секунд</p></div><span class="stat-icon">{{ icon('activity', 'icon-sm') }}</span></div>
<div class="card-body load-grid">
<div class="load-row">
<div class="row-between"><span class="subtle">{{ icon('cpu', 'icon-sm') }}Процессор</span><strong data-stat="cpu">—</strong></div>
<div class="meter" data-stat="cpu-meter"><span data-stat="cpu-bar" style="--value:0%"></span></div>
</div>
<div class="load-row">
<div class="row-between"><span class="subtle">{{ icon('memory-stick', 'icon-sm') }}Память</span><strong data-stat="mem">—</strong></div>
<div class="meter" data-stat="mem-meter"><span data-stat="mem-bar" style="--value:0%"></span></div>
</div>
</div>
</article>
{% endif %}
{% if vmops_saved(vm.dominfo) %}
<article class="card">
<div class="card-body stack-sm">
<div class="row" style="flex-wrap:nowrap;align-items:flex-start"><span class="list-icon brand">{{ icon('moon') }}</span><div class="grow"><strong>Машина в спящем режиме</strong><div class="subtle small">При запуске она продолжит работу с того же места: открытые программы и окна останутся.</div></div></div>
<form method="post" action="/vm/{{ vm.name }}/ops/forget-save" data-confirm="Сохранённое состояние будет удалено: всё, что было открыто внутри машины и не сохранено, пропадёт. При запуске система загрузится заново." data-confirm-title="Забыть сохранённое состояние?" data-confirm-button="Забыть" data-toast="Сохранённое состояние удалено" data-toast-tone="warning"><button class="btn btn-sm btn-danger-soft">{{ icon('x', 'icon-sm') }}Забыть сохранённое состояние</button></form>
</div>
</article>
{% endif %}
<article class="card">
<div class="card-header"><div><h2>Заметки</h2><p>Название видно в списке машин</p></div><span class="stat-icon">{{ icon('notebook-pen', 'icon-sm') }}</span></div>
<form method="post" action="/vm/{{ vm.name }}/notes/save" data-toast="Заметки сохранены" data-busy="Сохраняем…">
<div class="card-body stack-sm">
<div class="field">
<label for="vm-title">Название</label>
<input class="input" id="vm-title" name="title" maxlength="80" value="{{ notes.title }}" placeholder="Например: Сайт компании" autocomplete="off">
</div>
<div class="field">
<label for="vm-description">Описание</label>
<textarea class="input" id="vm-description" name="description" rows="4" maxlength="2000" placeholder="Что стоит внутри, кто отвечает, когда делать резервные копии…">{{ notes.description }}</textarea>
</div>
</div>
<div class="card-footer"><button class="btn btn-sm btn-primary" type="submit">Сохранить заметки</button></div>
</form>
</article>
+15
Просмотреть файл
@@ -0,0 +1,15 @@
{#- Пункты меню машины: пауза, продолжение, спящий режим, клонирование.
Перед include задайте ops_name (имя), ops_state (состояние virsh),
ops_next (куда вернуться после действия) и ops_hints (подсказки под пунктами). -#}
{% from "_ui.html" import icon %}
{%- set ops = ops_state|vm_state -%}
{%- macro item(label, hint, hints) -%}<span>{{ label }}{% if hints %}<small>{{ hint }}</small>{% endif %}</span>{%- endmacro -%}
{% if ops.running %}
<form method="post" action="/vm/{{ ops_name }}/ops/pause" data-toast="Машина {{ ops_name }} приостановлена" data-toast-tone="warning"><input type="hidden" name="next" value="{{ ops_next }}"><button class="menu-item">{{ icon('pause') }}{{ item('Приостановить', 'Заморозить, оставив в памяти', ops_hints) }}</button></form>
<form method="post" action="/vm/{{ ops_name }}/ops/hibernate" data-confirm="Память машины сохранится на диск сервера, а сама машина выключится. При следующем запуске она продолжит работу с этого же места." data-confirm-title="Перевести {{ ops_name }} в спящий режим?" data-confirm-button="Усыпить" data-confirm-tone="brand" data-toast="Машина {{ ops_name }} засыпает"><input type="hidden" name="next" value="{{ ops_next }}"><button class="menu-item">{{ icon('moon') }}{{ item('Спящий режим', 'Сохранить на диск и выключить', ops_hints) }}</button></form>
{% elif ops.raw == 'paused' %}
<form method="post" action="/vm/{{ ops_name }}/ops/resume" data-toast="Машина {{ ops_name }} продолжает работу"><input type="hidden" name="next" value="{{ ops_next }}"><button class="menu-item">{{ icon('play') }}{{ item('Продолжить', 'Снять с паузы', ops_hints) }}</button></form>
<form method="post" action="/vm/{{ ops_name }}/ops/hibernate" data-confirm="Память машины сохранится на диск сервера, а сама машина выключится. При следующем запуске она продолжит работу с этого же места." data-confirm-title="Перевести {{ ops_name }} в спящий режим?" data-confirm-button="Усыпить" data-confirm-tone="brand" data-toast="Машина {{ ops_name }} засыпает"><input type="hidden" name="next" value="{{ ops_next }}"><button class="menu-item">{{ icon('moon') }}{{ item('Спящий режим', 'Сохранить на диск и выключить', ops_hints) }}</button></form>
{% endif %}
<a class="menu-item" href="/vm/{{ ops_name }}/clone">{{ icon('copy') }}{{ item('Клонировать', 'Точная копия с новым именем', ops_hints) }}</a>
<div class="menu-sep"></div>
+8 -2
Просмотреть файл
@@ -2,6 +2,7 @@
{% from "_ui.html" import icon, badge, vm_badge, op_badge, meter, alert %}
{% set page_title = 'Обзор' %}
{% set running = vms|selectattr('state', 'in', ['running', 'idle', 'blocked'])|list|length %}
{% set titles = vmops_titles() if vms else {} %}
{% block page_header %}
<div class="page-header">
@@ -26,6 +27,7 @@
{% if profile.virtualization_warning %}
{{ alert('warning', 'Машины будут работать медленно. Включите аппаратную виртуализацию (Intel VT-x / AMD-V) в настройках BIOS сервера.', 'Аппаратное ускорение недоступно') }}
{% endif %}
{% include "_vm_ops_alerts.html" %}
{% endblock %}
{% block content %}
@@ -69,17 +71,20 @@
<span class="vm-avatar {{ 'on' if s.running else '' }}">{{ icon('monitor') }}<span class="status-dot {{ s.tone }}" data-live="dot"></span></span>
<span style="min-width:0">
<span class="vm-name truncate" style="display:block">{{ vm.name }}</span>
<span class="vm-sub"><span data-live="ip">{{ vm.ip if vm.ip and vm.ip != '—' else ('IP появится после запуска' if not s.running else 'Получает адрес…') }}</span></span>
<span class="vm-sub">{% if titles.get(vm.name) %}<span class="vm-title">{{ titles[vm.name] }}</span> · {% endif %}<span data-live="ip">{{ vm.ip if vm.ip and vm.ip != '—' else ('IP появится после запуска' if not s.running else 'Получает адрес…') }}</span></span>
</span>
</a>
<div class="vm-col">
<div class="vm-cell-label">Ресурсы</div>
<div class="vm-cell-value">{{ vm.vcpus or '—' }} {{ plural(vm.vcpus or 0, 'ядро', 'ядра', 'ядер') }} · {{ vm.memory_mb|format_mb }}</div>
{% include "_vm_live_meters.html" %}
</div>
<div class="vm-col">{{ vm_badge(vm.state) }}</div>
<div class="vm-actions">
{% if s.running %}
<a class="btn btn-sm" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">{{ icon('monitor', 'icon-sm') }}<span class="btn-label">Экран</span></a>
{% elif vm.state == 'paused' %}
<form method="post" action="/vm/{{ vm.name }}/ops/resume" class="inline-form" data-toast="Машина {{ vm.name }} продолжает работу"><input type="hidden" name="next" value="/"><button class="btn btn-sm">{{ icon('play', 'icon-sm') }}<span class="btn-label">Продолжить</span></button></form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/start" class="inline-form" data-toast="Машина {{ vm.name }} запускается"><button class="btn btn-sm">{{ icon('play', 'icon-sm') }}<span class="btn-label">Запустить</span></button></form>
{% endif %}
@@ -87,13 +92,14 @@
<summary class="btn btn-sm btn-icon btn-ghost" aria-label="Действия">{{ icon('ellipsis') }}</summary>
<div class="menu-list">
<a class="menu-item" href="/vm/{{ vm.name }}">{{ icon('settings') }}Открыть настройки</a>
{% set ops_name, ops_state, ops_next, ops_hints = vm.name, vm.state, '/', false %}{% include "_vm_ops_menu.html" %}
{% if s.running %}
<a class="menu-item" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран</a>
<form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина {{ vm.name }} перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}Перезагрузить</button></form>
<form method="post" action="/vm/{{ vm.name }}/shutdown" data-toast="Машина {{ vm.name }} выключается"><button class="menu-item">{{ icon('power') }}Выключить</button></form>
<div class="menu-sep"></div>
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить {{ vm.name }} принудительно?" data-confirm-button="Выключить" data-toast="Машина {{ vm.name }} выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}Выключить принудительно</button></form>
{% else %}
{% elif vm.state != 'paused' %}
<form method="post" action="/vm/{{ vm.name }}/start" data-toast="Машина {{ vm.name }} запускается"><button class="menu-item">{{ icon('play') }}Запустить</button></form>
<div class="menu-sep"></div>
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}Удалить машину</button></form>
+71
Просмотреть файл
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% from "_ui.html" import icon, vm_badge, alert %}
{% set page_title = 'Клонировать ' ~ vm_name %}
{% set st = state|vm_state %}
{% block page_header %}
<div class="page-header">
<div>
<div class="eyebrow"><a href="/vm/{{ vm_name }}">{{ icon('arrow-left', 'icon-sm') }} {{ vm_name }}</a></div>
<div class="title-row"><h1>Клонировать {{ vm_name }}</h1><span data-live-vm="{{ vm_name }}" data-running="{{ '1' if st.running else '0' }}" data-reload-on-change>{{ vm_badge(state, 'lg') }}</span></div>
<p class="lead">Точная копия машины: те же диски, процессор и память. У копии будет своё имя и свой сетевой адрес.</p>
</div>
</div>
{% endblock %}
{% block alerts %}
{% if error %}{{ alert('danger', error) }}{% endif %}
{% endblock %}
{% block content %}
<section class="grid grid-main">
<div class="stack">
<article class="card">
<div class="card-header"><div><h2>Новая машина</h2><p>Копия появится в списке машин выключенной</p></div></div>
{% if blockers %}
<div class="card-body stack-sm" style="padding-bottom:0">
{% if 'running' in blockers %}
<div class="alert warning" role="status">{{ icon('triangle-alert') }}<div><strong>Сначала выключите машину</strong>Диски копируются только у выключенной машины, иначе копия может получиться повреждённой.</div>
{% if st.running %}<form method="post" action="/vm/{{ vm_name }}/shutdown" class="alert-action" data-toast="Машина выключается — это может занять до минуты"><button class="btn btn-sm">{{ icon('power', 'icon-sm') }}Выключить</button></form>{% endif %}
</div>
{% endif %}
{% if 'space' in blockers %}{{ alert('danger', 'Копии нужно ' ~ needed_text ~ ', а свободно только ' ~ free_text ~ '. Удалите ненужные машины или образы.', 'Не хватает места на сервере') }}{% endif %}
{% if 'no_disks' in blockers %}{{ alert('danger', 'У машины нет дисков, которые можно скопировать.') }}{% endif %}
{% if 'busy' in blockers %}{{ alert('info', 'Эта машина уже клонируется. Дождитесь окончания в разделе «Задачи».') }}{% endif %}
</div>
{% endif %}
<form method="post" action="/vm/{{ vm_name }}/clone/start" data-busy="Запускаем…">
<div class="card-body form-grid">
<div class="field">
<label for="new_name">Имя копии</label>
<input class="input" id="new_name" name="new_name" value="{{ new_name }}" required pattern="[A-Za-z0-9][A-Za-z0-9_.\-]{1,62}" maxlength="63" autocomplete="off" {% if blockers %}disabled{% endif %}>
<span class="hint">Латинские буквы, цифры, точка, дефис и подчёркивание. Например: {{ suggested }}</span>
</div>
<div class="kv">
<div style="grid-column: span 2"><span>Что скопируется</span><strong>{% for d in disks %}{{ d.name }}{% if d.size_text %} · {{ d.size_text }}{% endif %}{% if not loop.last %}<br>{% endif %}{% else %}—{% endfor %}</strong></div>
<div><span>Нужно места</span><strong>{{ needed_text }}</strong></div>
<div><span>Свободно на сервере</span><strong>{{ free_text }}</strong></div>
</div>
</div>
<div class="card-footer">
<span class="subtle small" style="margin-right:auto">Занимает от минуты до часа — зависит от размера дисков</span>
<a class="btn" href="/vm/{{ vm_name }}">Отмена</a>
<button class="btn btn-primary" type="submit" {% if blockers %}disabled{% endif %}>{{ icon('copy') }}Клонировать</button>
</div>
</form>
</article>
</div>
<aside class="stack">
<article class="card">
<div class="card-header"><h2>Как это работает</h2></div>
<div class="list">
<div class="list-item"><span class="list-icon brand">{{ icon('hard-drive') }}</span><div class="grow"><div class="title">Диски копируются целиком</div><div class="meta">Внутри копии те же система, программы и пароли.</div></div></div>
<div class="list-item"><span class="list-icon">{{ icon('network') }}</span><div class="grow"><div class="title">Свой сетевой адрес</div><div class="meta">Копия получит новый MAC-адрес, а IP появится после запуска.</div></div></div>
<div class="list-item"><span class="list-icon">{{ icon('monitor') }}</span><div class="grow"><div class="title">Смените имя компьютера</div><div class="meta">Внутри системы копия называется как оригинал. Переименуйте её после первого запуска, чтобы не путаться.</div></div></div>
<div class="list-item"><span class="list-icon">{{ icon('layers') }}</span><div class="grow"><div class="title">Снимки не копируются</div><div class="meta">В копию попадает только текущее состояние дисков.</div></div></div>
</div>
</article>
</aside>
</section>
{% endblock %}
+6 -10
Просмотреть файл
@@ -21,12 +21,15 @@
{% 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>
@@ -48,6 +51,7 @@
{% if msg %}{{ alert('success', msg) }}{% endif %}
{% if err %}{{ alert('danger', err) }}{% endif %}
{% endfor %}
{% include "_vm_ops_alerts.html" %}
{% endblock %}
{% block content %}
@@ -98,6 +102,7 @@
</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 'Машина будет запускаться вместе с сервером' }}">
@@ -191,16 +196,7 @@
</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>
{% include "_vm_disks.html" %}
<article class="card">
<div class="card-header"><h2>Сетевые карты</h2></div>