Сравнить коммиты
2
Коммитов
f31b4ade1d
...
v0.12.5
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
ca5d49266c | ||
|
|
ec9428c2eb |
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
||||||
|
|
||||||
Текущая версия: **0.12.4** (файл `VERSION`, журнал изменений — `updates/versions.json`).
|
Текущая версия: **0.12.5** (файл `VERSION`, журнал изменений — `updates/versions.json`).
|
||||||
|
|
||||||
Что умеет:
|
Что умеет:
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
sudo apt install -y xorriso git curl python3-pip
|
sudo apt install -y xorriso git curl python3-pip
|
||||||
git clone https://git.stackworks.ru/viktor/virtuality.git
|
git clone https://git.stackworks.ru/viktor/virtuality.git
|
||||||
cd virtuality
|
cd virtuality
|
||||||
make iso # dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso
|
make iso # dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
|
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
|
||||||
@@ -168,7 +168,7 @@ sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панел
|
|||||||
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
|
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
|
||||||
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
|
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
|
||||||
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
|
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
|
||||||
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками.
|
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается).
|
||||||
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
|
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
|
||||||
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
|
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
|
||||||
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
|
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.12.4
|
0.12.5
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ make iso ARCH=arm64 # ARM64-серверы с UEFI
|
|||||||
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
|
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso
|
dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso
|
||||||
dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso.sha256
|
dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
|
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
|
||||||
@@ -109,7 +109,7 @@ make iso ISO_ARGS="--unattended --username sysadmin --password-hash '$(openssl p
|
|||||||
|
|
||||||
## Что спрашивает установщик
|
## Что спрашивает установщик
|
||||||
|
|
||||||
Меню загрузки: **Установить Virtuality 0.12.4** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
|
Меню загрузки: **Установить Virtuality 0.12.5** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
|
||||||
|
|
||||||
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
|
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
|
||||||
|
|
||||||
@@ -161,14 +161,14 @@ cat /var/log/virtuality/firstboot.log
|
|||||||
Сверьте контрольную сумму скачанного или собранного образа:
|
Сверьте контрольную сумму скачанного или собранного образа:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum -c virtuality-0.12.4-ubuntu-26.04-amd64.iso.sha256
|
sha256sum -c virtuality-0.12.5-ubuntu-26.04-amd64.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
|
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xorriso -indev virtuality-0.12.4-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
xorriso -indev virtuality-0.12.5-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
||||||
xorriso -osirrox on -indev virtuality-0.12.4-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
|
xorriso -osirrox on -indev virtuality-0.12.5-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -182,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.12.4-ubuntu-26.04-amd64.iso -extract /au
|
|||||||
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
|
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo dd if=dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
sudo dd if=dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
||||||
```
|
```
|
||||||
|
|
||||||
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
|
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Regressions for the defects found by the 0.11 audit."""
|
"""Regressions for the defects found by the 0.11 audit."""
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
import app
|
import app
|
||||||
import conftest
|
import conftest
|
||||||
@@ -69,6 +69,46 @@ def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch):
|
|||||||
assert response.headers["location"].startswith("/vm/web01?error=")
|
assert response.headers["location"].startswith("/vm/web01?error=")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_delete_stops_paused_machine_first(logged_in, data_dirs, monkeypatch):
|
||||||
|
import core
|
||||||
|
|
||||||
|
calls = recording(monkeypatch, app)
|
||||||
|
recording(monkeypatch, core, [(["virsh", "domstate"], conftest.fake_result("paused"))])
|
||||||
|
logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||||
|
assert find(calls, "virsh", "destroy", "web01") and find(calls, "virsh", "undefine", "web01")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_delete_waits_for_running_task(logged_in, data_dirs, monkeypatch):
|
||||||
|
import core
|
||||||
|
|
||||||
|
calls = recording(monkeypatch, app)
|
||||||
|
backup = core.new_operation("backup", "Резервная копия web01", vm_name="web01")
|
||||||
|
core.update_operation(backup, status="running")
|
||||||
|
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||||
|
assert "Резервная копия web01" in unquote(response.headers["location"])
|
||||||
|
assert not find(calls, "virsh", "undefine") and not find(calls, "virsh", "destroy")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_delete_removes_its_port_forwards(logged_in, data_dirs, monkeypatch):
|
||||||
|
recording(monkeypatch, app)
|
||||||
|
rule = {"guest_ip": "192.168.100.51", "external_port_start": 2222, "external_port_end": 2222, "guest_port_start": 22, "guest_port_end": 22, "protocol": "tcp"}
|
||||||
|
data_dirs["network"].joinpath("port_forwards.json").write_text(json.dumps([
|
||||||
|
{"id": "1", "vm_name": "web01", **rule},
|
||||||
|
{"id": "2", "vm_name": "db01", **rule, "external_port_start": 2223, "external_port_end": 2223, "guest_ip": "192.168.100.52"},
|
||||||
|
]))
|
||||||
|
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||||
|
assert "убраны пробросы портов: 1" in unquote(response.headers["location"])
|
||||||
|
assert [item["vm_name"] for item in network_core.load_port_forwards()] == ["db01"]
|
||||||
|
assert "2222" not in (data_dirs["nft"] / "virtuality.nft").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_is_offered_for_running_machine(logged_in):
|
||||||
|
html = logged_in.get("/vm/web01").text # в тестах web01 работает
|
||||||
|
assert 'action="/vm/web01/delete"' in html and "она будет сразу выключена" in html
|
||||||
|
dashboard = logged_in.get("/").text
|
||||||
|
assert 'action="/vm/web01/delete"' in dashboard and 'action="/vm/db01/delete"' in dashboard
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158)
|
# ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158)
|
||||||
def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch):
|
def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch):
|
||||||
iso = data_dirs["iso"] / "debian.iso"
|
iso = data_dirs["iso"] / "debian.iso"
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
{
|
{
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.12.5",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Удаление машины в любом состоянии",
|
||||||
|
"summary": "Пункт «Удалить машину» теперь есть всегда, а не только у выключенной машины. Работающую или приостановленную машину панель сначала выключит, а подтверждение честно об этом предупреждает. Вместе с машиной удаляются её пробросы портов.",
|
||||||
|
"features": [
|
||||||
|
"«Удалить машину» доступно в меню машины и в списке машин при любом состоянии",
|
||||||
|
"Работающая или приостановленная машина перед удалением выключается",
|
||||||
|
"Пока с машиной идёт копия, клон или другая задача, удалить её нельзя: панель просит дождаться завершения",
|
||||||
|
"Вместе с машиной удаляются её пробросы портов, чтобы они не вели на адрес, который достанется другой машине",
|
||||||
|
"Версия обновлена до 0.12.5"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.12.4",
|
"version": "0.12.4",
|
||||||
"date": "2026-09-27",
|
"date": "2026-09-27",
|
||||||
|
|||||||
+9
-2
@@ -2094,14 +2094,21 @@ def vm_action(request: Request, name: str, action: str):
|
|||||||
if action in ("shutdown", "destroy", "delete"):
|
if action in ("shutdown", "destroy", "delete"):
|
||||||
backups.cancel_late_restart(name)
|
backups.cancel_late_restart(name)
|
||||||
if action == "delete":
|
if action == "delete":
|
||||||
if vm_state(name) == "running":
|
import core
|
||||||
|
|
||||||
|
busy = core.active_operations_for(name)
|
||||||
|
if busy:
|
||||||
|
return redirect_with_message(f"/vm/{name}", "error", f"С машиной сейчас идёт задача «{busy[0].get('title') or 'операция'}». Дождитесь её завершения и удалите машину снова.")
|
||||||
|
if core.vm_is_running(name): # и на паузе тоже: undefine активной машины оставил бы её работать без настроек
|
||||||
run_cmd(["virsh", "destroy", name], timeout=20)
|
run_cmd(["virsh", "destroy", name], timeout=20)
|
||||||
# --nvram: UEFI machines keep firmware variables that block a plain undefine.
|
# --nvram: UEFI machines keep firmware variables that block a plain undefine.
|
||||||
result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120)
|
result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120)
|
||||||
if not result["ok"]:
|
if not result["ok"]:
|
||||||
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}")
|
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}")
|
||||||
network_core.release_nat_address(name)
|
network_core.release_nat_address(name)
|
||||||
return redirect_with_message("/", "message", f"Машина {name} удалена")
|
removed = network_core.remove_vm_forwards(name)
|
||||||
|
message = f"Машина {name} удалена" + (f", вместе с ней убраны пробросы портов: {removed}" if removed else "")
|
||||||
|
return redirect_with_message("/", "message", message)
|
||||||
if action not in allowed:
|
if action not in allowed:
|
||||||
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
|
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
|
||||||
result = run_cmd(allowed[action], timeout=30)
|
result = run_cmd(allowed[action], timeout=30)
|
||||||
|
|||||||
@@ -436,6 +436,17 @@ def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port
|
|||||||
return normalize_forward(forward)
|
return normalize_forward(forward)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_vm_forwards(vm_name: str) -> int:
|
||||||
|
"""Машина удалена: её пробросы вели бы на адрес, который потом может достаться другой машине."""
|
||||||
|
items = load_port_forwards()
|
||||||
|
kept = [item for item in items if item.get('vm_name') != vm_name]
|
||||||
|
if len(kept) == len(items):
|
||||||
|
return 0
|
||||||
|
save_port_forwards(kept)
|
||||||
|
apply_port_forwards()
|
||||||
|
return len(items) - len(kept)
|
||||||
|
|
||||||
|
|
||||||
def delete_port_forward(forward_id: str) -> None:
|
def delete_port_forward(forward_id: str) -> None:
|
||||||
items = [item for item in load_port_forwards() if item.get('id') != forward_id]
|
items = [item for item in load_port_forwards() if item.get('id') != forward_id]
|
||||||
save_port_forwards(items)
|
save_port_forwards(items)
|
||||||
|
|||||||
@@ -101,9 +101,9 @@
|
|||||||
<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>
|
<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>
|
||||||
{% elif vm.state != 'paused' %}
|
{% elif vm.state != 'paused' %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/start" data-toast="Машина {{ vm.name }} запускается"><button class="menu-item">{{ icon('play') }}Запустить</button></form>
|
<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>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="{% if vm.state != 'shut off' %}Машина {{ vm.name }} работает: она будет сразу выключена, как при отключении питания, а затем удалена вместе со всеми дисками.{% else %}Машина {{ vm.name }} будет удалена вместе со всеми дисками.{% endif %} Отменить это нельзя. Резервные копии машины останутся, из них её можно восстановить." 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>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,9 +33,8 @@
|
|||||||
{% if s.running %}
|
{% 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 }}/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>
|
<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 %}
|
{% endif %}
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="{% if vm.state != 'shut off' %}Машина {{ vm.name }} работает: она будет сразу выключена, как при отключении питания, а затем удалена вместе со всеми дисками.{% else %}Машина {{ vm.name }} будет удалена вместе со всеми дисками.{% endif %} Отменить это нельзя. Резервные копии машины останутся, из них её можно восстановить." data-confirm-title="Удалить машину {{ vm.name }}?" 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>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user