Cloud-образы, клонирование VM и шаблоны были добавлены

Каталог официальных cloud-образов Ubuntu/Debian (x86_64 и aarch64) со
скачиванием в фоне, страница /cloud-images, создание VM из cloud-образа
с настройкой пользователя, пароля и SSH-ключа через cloud-init seed,
клонирование VM через virt-clone и флаг шаблона с блокировкой запуска.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw
Этот коммит содержится в:
Claude
2026-08-15 19:14:53 +00:00
родитель 0c95e8e516
Коммит 9e54614869
7 изменённых файлов: 676 добавлений и 13 удалений
+4 -2
Просмотреть файл
@@ -6,11 +6,13 @@
</div>
<nav class="v-nav">
<a data-icon="⌂" class="{{ 'active' if path == '/' else '' }}" href="/">Обзор</a>
<a data-icon="" class="{{ 'active' if path.startswith('/vm/create') else '' }}" href="/vm/create">Создать VM</a>
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') else '' }}">
<a data-icon="" class="{{ 'active' if path == '/vm/create' else '' }}" href="/vm/create">Создать VM</a>
<a data-icon="☁" class="{{ 'active' if path == '/vm/create-cloud' else '' }}" href="/vm/create-cloud">VM из облака</a>
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') or path.startswith('/cloud-images') else '' }}">
<button class="v-nav-group-title" type="button">Образы</button>
<a data-icon="◎" class="{{ 'active' if path.startswith('/iso') else '' }}" href="/iso">ISO</a>
<a data-icon="▣" class="{{ 'active' if path.startswith('/disk-images') else '' }}" href="/disk-images">Диски</a>
<a data-icon="☁" class="{{ 'active' if path.startswith('/cloud-images') else '' }}" href="/cloud-images">Cloud</a>
</div>
<a data-icon="⇄" class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
<a data-icon="⚙" class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
+116
Просмотреть файл
@@ -0,0 +1,116 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloud-образы — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="v-layout">
{% include "_sidebar.html" %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Cloud-образы</div>
<div class="v-subtitle">Официальные cloud-образы Ubuntu и Debian для быстрого создания VM без установки с ISO</div>
</div>
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
{% if error %}
<div class="alert danger">{{ error }}</div>
{% endif %}
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Каталог образов</h2>
<span class="pill">официальные зеркала</span>
</div>
<table>
<thead>
<tr><th>Система</th><th>Архитектура</th><th>Статус</th><th></th></tr>
</thead>
<tbody>
{% for entry in catalog %}
<tr>
<td class="strong">{{ entry.label }}</td>
<td><span class="status {{ 'ok' if entry.native else 'warn' }}">{{ entry.arch }}{% if entry.native %} · родная{% endif %}</span></td>
<td>{% if entry.downloaded %}<span class="status ok">скачан</span>{% else %}<span class="muted">не скачан</span>{% endif %}</td>
<td class="actions">
<form method="post" action="/cloud-images/download">
<input type="hidden" name="key" value="{{ entry.key }}">
<button class="{{ 'ghost' if entry.downloaded else 'primary' }}">{{ 'Скачать заново' if entry.downloaded else 'Скачать' }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted small-note">Образы качаются с cloud-images.ubuntu.com и cloud.debian.org в фоне — прогресс виден в разделе «Операции». Для Raspberry / Orange Pi выбирай aarch64.</p>
</article>
<article class="card">
<div class="card-head">
<h2>Зачем это нужно</h2>
<span class="pill">VM за минуту</span>
</div>
<div class="quick-actions">
<div class="big-action static">
<strong>Быстрее, чем ISO</strong>
<span>Cloud-образ — уже установленная система. VM создаётся копированием диска и стартует сразу, без установщика.</span>
</div>
<div class="big-action static">
<strong>Логин и SSH-ключ сразу</strong>
<span>При создании VM задаёшь пользователя, пароль или SSH-ключ — cloud-init настроит их при первом старте.</span>
</div>
<div class="big-action static">
<strong>ARM64 без боли</strong>
<span>Для Raspberry Pi и Orange Pi это основной способ создавать гостевые VM.</span>
</div>
</div>
<a class="link-pill" href="/vm/create-cloud">→ Создать VM из cloud-образа</a>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Скачанные образы</h2>
<span class="pill">{{ images|length }} файлов</span>
</div>
<table>
<thead>
<tr><th>Файл</th><th>Система</th><th>Архитектура</th><th>Размер</th><th>Обновлён</th><th>Действия</th></tr>
</thead>
<tbody>
{% for image in images %}
<tr>
<td class="strong">{{ image.name }}</td>
<td>{{ image.label }}</td>
<td><span class="status ok">{{ image.arch }}</span></td>
<td>{{ image.size }}</td>
<td>{{ image.updated }}</td>
<td class="actions">
<a class="link-pill" href="/vm/create-cloud">Создать VM</a>
<form method="post" action="/cloud-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');">
<button class="danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">Образов пока нет. Скачай Ubuntu или Debian из каталога выше.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</div>
<script src="/static/panel.js" defer></script>
</body>
</html>
+137
Просмотреть файл
@@ -0,0 +1,137 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VM из cloud-образа — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="v-layout">
{% include "_sidebar.html" %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">VM из cloud-образа</div>
<div class="v-subtitle">Готовая система с пользователем и SSH за минуту — без установки с ISO</div>
</div>
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
{% if error %}
<div class="alert danger">{{ error }}</div>
{% endif %}
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Параметры</h2>
<span class="pill">cloud-init</span>
</div>
<form method="post" action="/vm/create-cloud" class="form-grid">
<label>
<span>Имя VM</span>
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-web" required>
</label>
<label>
<span>Cloud-образ</span>
<select name="image_name" {% if images %}required{% endif %}>
{% for image in images %}
<option value="{{ image.name }}" {% if form.image_name == image.name %}selected{% endif %}>{{ image.label }} — {{ image.arch }} — {{ image.size }}</option>
{% endfor %}
</select>
</label>
<div class="form-row">
<label>
<span>RAM, MB</span>
<input type="number" name="memory" min="512" max="262144" step="512" value="{{ form.memory }}" required>
</label>
<label>
<span>CPU</span>
<input type="number" name="vcpus" min="1" max="128" value="{{ form.vcpus }}" required>
</label>
</div>
<label>
<span>Диск, GB</span>
<input type="number" name="disk_size" min="4" max="4096" value="{{ form.disk_size }}" required>
</label>
<div class="form-row">
<label>
<span>Пользователь в VM</span>
<input type="text" name="username" value="{{ form.username or 'admin' }}" placeholder="admin" required>
</label>
<label>
<span>Пароль (необязательно, если есть SSH-ключ)</span>
<input type="password" name="password" value="" placeholder="пароль для входа">
</label>
</div>
<label>
<span>Публичный SSH-ключ (необязательно)</span>
<input type="text" name="ssh_key" value="{{ form.ssh_key or '' }}" placeholder="ssh-ed25519 AAAA…">
</label>
<label>
<span>Режим сети</span>
<select name="network_mode" required>
<option value="nat" {% if form.network_mode != 'bridge' %}selected{% endif %}>VPS NAT Router — virtuality-nat</option>
<option value="bridge" {% if form.network_mode == 'bridge' %}selected{% endif %}>Bridge — br0 / локальная сеть</option>
</select>
</label>
<label>
<span>Bridge для режима Bridge</span>
<input type="text" name="bridge" value="{{ form.bridge }}" placeholder="br0">
</label>
<div class="notice">
Пользователь и пароль/ключ будут настроены cloud-init при первом старте VM. Вход: <b>ssh пользователь@IP</b> или через консоль на странице VM.
</div>
<button type="submit" class="primary wide" {% if not images %}disabled{% endif %}>Создать VM из cloud-образа</button>
{% if not images %}
<div class="alert danger">Сначала скачай cloud-образ на странице <a href="/cloud-images">Cloud-образы</a>.</div>
{% endif %}
</form>
</article>
<article class="card">
<div class="card-head">
<h2>Как это работает</h2>
<a class="link-pill" href="/cloud-images">Открыть cloud-образы</a>
</div>
<div class="quick-actions">
<div class="big-action static">
<strong>1. Копия образа</strong>
<span>Скачанный cloud-образ копируется в новый qcow2-диск VM и расширяется до выбранного размера.</span>
</div>
<div class="big-action static">
<strong>2. Seed-диск cloud-init</strong>
<span>Из имени, пользователя и пароля/ключа собирается маленький seed-ISO, который VM читает при первом старте.</span>
</div>
<div class="big-action static">
<strong>3. Импорт и запуск</strong>
<span>virt-install импортирует диск и запускает VM. Прогресс — в разделе «Операции».</span>
</div>
</div>
<pre>qemu-img convert -O qcow2 cloud.qcow2 vm.qcow2
qemu-img resize vm.qcow2 20G
cloud-localds seed.iso user-data meta-data
virt-install --import --disk vm.qcow2 …</pre>
</article>
</section>
</main>
</div>
<script src="/static/panel.js" defer></script>
</body>
</html>
+36
Просмотреть файл
@@ -75,6 +75,9 @@
{% if iso_error %}<div class="alert danger">{{ iso_error }}</div>{% endif %}
{% if boot_message %}<div class="alert success">{{ boot_message }}</div>{% endif %}
{% if boot_error %}<div class="alert danger">{{ boot_error }}</div>{% endif %}
{% if clone_message %}<div class="alert success">{{ clone_message }}</div>{% endif %}
{% if clone_error %}<div class="alert danger">{{ clone_error }}</div>{% endif %}
{% if is_template %}<div class="alert warn">Эта VM помечена как шаблон: запуск заблокирован. Клонируй её для создания новых машин.</div>{% endif %}
<section class="grid vm-four-grid">
<article class="card">
@@ -244,6 +247,39 @@
</article>
</section>
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Клонирование</h2>
<span class="pill">virt-clone</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/clone" class="form-grid">
<label>
<span>Имя новой VM</span>
<input type="text" name="new_name" placeholder="{{ vm.name }}-copy" required>
</label>
<button class="primary wide" {% if is_running %}disabled{% endif %}>Клонировать VM</button>
{% if is_running %}
<p class="muted small-note">Клонирование доступно только для выключенной VM — сначала выключи её.</p>
{% else %}
<p class="muted small-note">Диски копируются целиком, MAC-адрес нового клона генерируется автоматически. Прогресс — в «Операциях».</p>
{% endif %}
</form>
</article>
<article class="card">
<div class="card-head">
<h2>Шаблон</h2>
<span class="pill">{{ 'template' if is_template else 'обычная VM' }}</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/template" class="form-grid">
<input type="hidden" name="enabled" value="{{ '0' if is_template else '1' }}">
<button class="{{ 'ghost' if is_template else 'primary' }} wide">{{ 'Снять флаг шаблона' if is_template else 'Пометить как шаблон' }}</button>
</form>
<p class="muted small-note">Шаблон — эталонная VM для клонирования: запуск заблокирован, чтобы случайно не изменить эталонный диск. Подготовь VM, выключи её и пометь как шаблон.</p>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Консоль</h2>