192 строки
8.0 KiB
HTML
192 строки
8.0 KiB
HTML
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Диски — {{ app_name }}</title>
|
|
<link rel="stylesheet" href="/static/app.css">
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<header class="topbar">
|
|
<div>
|
|
<div class="brand">Диски</div>
|
|
<div class="muted">Готовые локальные образы .img / .raw / .qcow2 для импорта VM</div>
|
|
</div>
|
|
<div class="top-meta">
|
|
<span>user: {{ user }}</span>
|
|
<a class="link-pill" href="/">← Дашборд</a>
|
|
<a class="link-pill" href="/iso">ISO</a>
|
|
<a class="link-pill accent" href="/vm/create">+ Создать VM</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">.img .raw .qcow2</span>
|
|
</div>
|
|
<form id="disk-upload-form" method="post" action="/disk-images/upload" enctype="multipart/form-data" class="form-grid">
|
|
<label class="upload-box">
|
|
<span>Выбери файл образа</span>
|
|
<input id="disk-file-input" type="file" name="image_file" accept=".img,.raw,.qcow2,application/octet-stream" required>
|
|
</label>
|
|
<div id="disk-file-hint" class="muted small-note">Файл ещё не выбран.</div>
|
|
<div id="upload-progress-wrap" class="upload-progress-wrap" hidden>
|
|
<div class="upload-progress-head">
|
|
<strong id="upload-status">Подготовка…</strong>
|
|
<span id="upload-percent">0%</span>
|
|
</div>
|
|
<div class="progress big"><span id="upload-progress-bar"></span></div>
|
|
<div class="upload-progress-meta">
|
|
<span id="upload-loaded">0 MB</span>
|
|
<span id="upload-speed">скорость считается…</span>
|
|
</div>
|
|
</div>
|
|
<button id="disk-upload-button" type="submit" class="primary wide">Загрузить образ диска</button>
|
|
</form>
|
|
<p class="muted small-note">Файл попадёт в <b>/var/lib/virtuality/disk-images</b>. При создании VM образ будет скопирован/сконвертирован в отдельный <b>qcow2</b>-диск VM.</p>
|
|
</article>
|
|
|
|
<article class="card">
|
|
<div class="card-head">
|
|
<h2>Как использовать</h2>
|
|
<span class="pill">import disk</span>
|
|
</div>
|
|
<div class="quick-actions">
|
|
<div class="big-action static">
|
|
<strong>Raspberry / Orange Pi images</strong>
|
|
<span>Загрузи .img, затем в “Создать VM” выбери режим “Готовый диск”.</span>
|
|
</div>
|
|
<div class="big-action static">
|
|
<strong>Оригинал не трогаем</strong>
|
|
<span>Virtuality делает отдельный диск VM, чтобы загруженный образ оставался шаблоном.</span>
|
|
</div>
|
|
</div>
|
|
<pre>qemu-img convert -O qcow2 source.img VM.qcow2
|
|
virt-install --import --disk path=VM.qcow2</pre>
|
|
</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></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for image in images %}
|
|
<tr>
|
|
<td class="strong">{{ image.name }}</td>
|
|
<td><span class="status ok">{{ image.format }}</span></td>
|
|
<td>{{ image.size }}</td>
|
|
<td>{{ image.updated }}</td>
|
|
<td class="actions">
|
|
<a class="link-pill" href="/vm/create">Создать VM</a>
|
|
<form method="post" action="/disk-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');">
|
|
<button class="danger">Удалить</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr><td colspan="5" class="muted">Образов пока нет. Загрузи .img, .raw или .qcow2.</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
const form = document.getElementById('disk-upload-form');
|
|
const fileInput = document.getElementById('disk-file-input');
|
|
const button = document.getElementById('disk-upload-button');
|
|
const hint = document.getElementById('disk-file-hint');
|
|
const box = document.getElementById('upload-progress-wrap');
|
|
const bar = document.getElementById('upload-progress-bar');
|
|
const percent = document.getElementById('upload-percent');
|
|
const statusLine = document.getElementById('upload-status');
|
|
const loadedLine = document.getElementById('upload-loaded');
|
|
const speedLine = document.getElementById('upload-speed');
|
|
|
|
function bytesText(bytes) {
|
|
if (!bytes) return '0 MB';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let value = bytes;
|
|
let i = 0;
|
|
while (value >= 1024 && i < units.length - 1) { value /= 1024; i++; }
|
|
return value.toFixed(value >= 10 || i === 0 ? 0 : 1) + ' ' + units[i];
|
|
}
|
|
|
|
fileInput.addEventListener('change', function () {
|
|
const file = fileInput.files[0];
|
|
hint.textContent = file ? file.name + ' · ' + bytesText(file.size) : 'Файл ещё не выбран.';
|
|
});
|
|
|
|
form.addEventListener('submit', function (event) {
|
|
event.preventDefault();
|
|
const file = fileInput.files[0];
|
|
if (!file) return;
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
const data = new FormData(form);
|
|
const started = Date.now();
|
|
|
|
box.hidden = false;
|
|
button.disabled = true;
|
|
fileInput.disabled = true;
|
|
statusLine.textContent = 'Передача образа на сервер…';
|
|
bar.style.width = '0%';
|
|
percent.textContent = '0%';
|
|
loadedLine.textContent = '0 MB из ' + bytesText(file.size);
|
|
speedLine.textContent = 'скорость считается…';
|
|
|
|
xhr.upload.onprogress = function (event) {
|
|
if (!event.lengthComputable) {
|
|
statusLine.textContent = 'Загрузка идёт…';
|
|
return;
|
|
}
|
|
const pct = Math.round((event.loaded / event.total) * 100);
|
|
const seconds = Math.max(1, (Date.now() - started) / 1000);
|
|
const speed = event.loaded / seconds;
|
|
bar.style.width = pct + '%';
|
|
percent.textContent = pct + '%';
|
|
loadedLine.textContent = bytesText(event.loaded) + ' из ' + bytesText(event.total);
|
|
speedLine.textContent = bytesText(speed) + '/s';
|
|
if (pct >= 100) statusLine.textContent = 'Файл передан. Сервер сохраняет образ…';
|
|
};
|
|
|
|
xhr.onload = function () {
|
|
if (xhr.status >= 200 && xhr.status < 400) {
|
|
bar.style.width = '100%';
|
|
percent.textContent = '100%';
|
|
statusLine.textContent = 'Готово. Обновляем список образов…';
|
|
window.location.href = '/disk-images';
|
|
} else {
|
|
statusLine.textContent = 'Ошибка загрузки: HTTP ' + xhr.status;
|
|
button.disabled = false;
|
|
fileInput.disabled = false;
|
|
}
|
|
};
|
|
|
|
xhr.onerror = function () {
|
|
statusLine.textContent = 'Ошибка сети во время загрузки.';
|
|
button.disabled = false;
|
|
fileInput.disabled = false;
|
|
};
|
|
|
|
xhr.open('POST', form.action);
|
|
xhr.send(data);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|