Исправлены ошибки второго раунда аудита: локаль virsh, имена машин, IP, ISO в приводе, консоль
- все команды запускаются с LC_ALL=C.UTF-8: русский перевод virsh ломал разбор состояния машин - имя машины не может быть числом или UUID (virsh принял бы его за ID другой машины) - IP без MAC машины не угадывается по чужой аренде DHCP; у выключенных машин нет устаревшего IP - вставленный ISO определяется по устройству CD-ROM, а не по «.iso» в пути - ISO, вставленный в привод машины, нельзя удалить — машина перестала бы запускаться - на ARM-платах нет ложного предупреждения про флаги vmx/svm - проверка машины в консоли не блокирует остальные запросы Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
@@ -140,6 +140,7 @@ LimitNOFILE=65536
|
|||||||
User=root
|
User=root
|
||||||
Group=root
|
Group=root
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=LC_ALL=C.UTF-8
|
||||||
Environment=VIRTUALITY_AUTH_USER=${auth_user}
|
Environment=VIRTUALITY_AUTH_USER=${auth_user}
|
||||||
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
|
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
|
||||||
Environment=VIRTUALITY_NODE_ENV=${NODE_ENV}
|
Environment=VIRTUALITY_NODE_ENV=${NODE_ENV}
|
||||||
|
|||||||
@@ -144,3 +144,60 @@ def test_snapshot_names_with_spaces_are_listed():
|
|||||||
|
|
||||||
rows = snapshots.parse_snapshot_list(" Name Creation Time State\n------\n before update 2026-09-25 10:12:03 +0000 shutoff\n clean 2026-09-10 12:05:00 +0000 running\n")
|
rows = snapshots.parse_snapshot_list(" Name Creation Time State\n------\n before update 2026-09-25 10:12:03 +0000 shutoff\n clean 2026-09-10 12:05:00 +0000 running\n")
|
||||||
assert [row["name"] for row in rows] == ["before update", "clean"]
|
assert [row["name"] for row in rows] == ["before update", "clean"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- second audit round
|
||||||
|
def test_vm_names_that_virsh_reads_as_id_or_uuid_are_rejected():
|
||||||
|
import core
|
||||||
|
|
||||||
|
for module in (app, core, network_core):
|
||||||
|
assert module.valid_vm_name("web01") and module.valid_vm_name("1c-server")
|
||||||
|
assert not module.valid_vm_name("10") and not module.valid_vm_name("6c1b3c1e-0000-0000-0000-000000000001")
|
||||||
|
|
||||||
|
|
||||||
|
def test_subprocesses_use_c_locale():
|
||||||
|
import os
|
||||||
|
|
||||||
|
import core
|
||||||
|
|
||||||
|
assert core.STORAGE_DIR and os.environ["LC_ALL"] == "C.UTF-8" # set when core is imported
|
||||||
|
|
||||||
|
|
||||||
|
def test_lease_is_not_guessed_without_mac(data_dirs, monkeypatch):
|
||||||
|
recording(monkeypatch, network_core, [
|
||||||
|
(["virsh", "domifaddr"], conftest.fake_result(ok=False, stderr="domain is not running")),
|
||||||
|
(["virsh", "domiflist"], conftest.fake_result(ok=False, stderr="failed to get domain")),
|
||||||
|
(["virsh", "net-dhcp-leases"], conftest.fake_result(" 2026-09-26 10:00:00 52:54:00:11:22:33 ipv4 192.168.100.77/24 other -")),
|
||||||
|
])
|
||||||
|
assert network_core.resolve_vm_ip("gone") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stopped_machines_have_no_stale_ip(data_dirs, monkeypatch):
|
||||||
|
calls = recording(monkeypatch, app)
|
||||||
|
rows = {row["name"]: row for row in app.parse_virsh_list()}
|
||||||
|
assert rows["db01"]["ip"] == "—" and rows["web01"]["ip"] == "192.168.100.51"
|
||||||
|
assert not [cmd for cmd in calls if cmd[:2] == ["virsh", "domifaddr"] and cmd[-1] == "db01"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_disk_named_like_iso_is_not_shown_as_iso(data_dirs, monkeypatch):
|
||||||
|
xml = conftest.DUMPXML.replace("<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>", "<disk type='file' device='disk'><source file='/var/lib/virtuality/images/web.iso.qcow2'/><target dev='vda' bus='virtio'/></disk><disk type='file' device='cdrom'><target dev='sda' bus='sata'/></disk>")
|
||||||
|
recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(xml))])
|
||||||
|
assert app.current_vm_iso("web01") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_iso_in_use_is_not_deleted(logged_in, data_dirs, monkeypatch):
|
||||||
|
iso = data_dirs["iso"] / "old.iso"
|
||||||
|
iso.write_bytes(b"iso")
|
||||||
|
xml = CDROM_XML.replace("/var/lib/virtuality/iso/old.iso", str(iso))
|
||||||
|
recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(xml)), (["virsh", "list", "--all", "--name"], conftest.fake_result("web01\n"))])
|
||||||
|
response = logged_in.post("/iso/old.iso/delete", follow_redirects=False)
|
||||||
|
assert "error=" in response.headers["location"] and iso.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_arm_board_is_ready_without_vmx_flags(monkeypatch):
|
||||||
|
import host_profile
|
||||||
|
|
||||||
|
monkeypatch.setattr(host_profile.platform, "machine", lambda: "aarch64")
|
||||||
|
monkeypatch.setattr(host_profile, "cpu_virtualization_flags_count", lambda: 0)
|
||||||
|
profile = host_profile.detect_host_profile()
|
||||||
|
assert not [check for check in profile["checks"] if check["name"] == "Поддержка виртуализации процессором"]
|
||||||
|
|||||||
+21
-14
@@ -467,11 +467,13 @@ def parse_virsh_list() -> list[dict[str, str]]:
|
|||||||
|
|
||||||
manual_ips = manual_ip_map()
|
manual_ips = manual_ip_map()
|
||||||
|
|
||||||
def resolve_ip(name: str) -> str:
|
def resolve_ip(name: str, state: str = "running") -> str:
|
||||||
if not name:
|
if not name:
|
||||||
return "—"
|
return "—"
|
||||||
if manual_ips.get(name):
|
if manual_ips.get(name):
|
||||||
return manual_ips[name]
|
return manual_ips[name]
|
||||||
|
if "running" not in state and "paus" not in state:
|
||||||
|
return "—"
|
||||||
macs = vm_macs(name)
|
macs = vm_macs(name)
|
||||||
for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)):
|
for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)):
|
||||||
try:
|
try:
|
||||||
@@ -499,7 +501,7 @@ def parse_virsh_list() -> list[dict[str, str]]:
|
|||||||
label = "enabled" if enabled else "disabled" if info["autostart_known"] else "unknown"
|
label = "enabled" if enabled else "disabled" if info["autostart_known"] else "unknown"
|
||||||
rows.append({"id": vm_id, "name": name, "state": state, "autostart_enabled": enabled, "autostart_label": label, "autostart_css": "ok" if enabled else "warn", "vcpus": info["vcpus"], "memory_mb": info["memory_mb"]})
|
rows.append({"id": vm_id, "name": name, "state": state, "autostart_enabled": enabled, "autostart_label": label, "autostart_css": "ok" if enabled else "warn", "vcpus": info["vcpus"], "memory_mb": info["memory_mb"]})
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row["ip"] = resolve_ip(row.get("name", ""))
|
row["ip"] = resolve_ip(row.get("name", ""), row.get("state", ""))
|
||||||
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
|
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
@@ -859,7 +861,10 @@ def disk_upload_response(request: Request, payload: dict[str, Any]):
|
|||||||
|
|
||||||
|
|
||||||
def valid_vm_name(name: str) -> bool:
|
def valid_vm_name(name: str) -> bool:
|
||||||
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""))
|
"""Latin letters, digits, dot, dash, underscore; never a bare number or a UUID — virsh would read those as an ID/UUID of another machine."""
|
||||||
|
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""):
|
||||||
|
return False
|
||||||
|
return not name.isdigit() and not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", name)
|
||||||
|
|
||||||
|
|
||||||
def vm_exists(name: str) -> bool:
|
def vm_exists(name: str) -> bool:
|
||||||
@@ -1096,16 +1101,14 @@ def vm_state(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def current_vm_iso(name: str) -> str:
|
def current_vm_iso(name: str) -> str:
|
||||||
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
|
return next((drive["source"] for drive in vm_cdrom_devices(name) if drive["source"]), "")
|
||||||
if not result.get("ok"):
|
|
||||||
return ""
|
|
||||||
for line in (result.get("stdout") or "").splitlines():
|
def vms_using_iso(path: Path) -> list[str]:
|
||||||
if ".iso" not in line.lower():
|
"""Machines that have this ISO in their CD-ROM drive (they would not start without the file)."""
|
||||||
continue
|
listed = run_cmd(["virsh", "list", "--all", "--name"], timeout=10)
|
||||||
parts = line.split()
|
target = str(path)
|
||||||
if parts:
|
return [name for name in (listed.get("stdout") or "").split() if any(drive["source"] == target for drive in vm_cdrom_devices(name))]
|
||||||
return parts[-1]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def vm_cdrom_devices(name: str) -> list[dict[str, str]]:
|
def vm_cdrom_devices(name: str) -> list[dict[str, str]]:
|
||||||
@@ -1397,6 +1400,9 @@ def iso_delete(request: Request, name: str):
|
|||||||
return auth_redirect
|
return auth_redirect
|
||||||
path = iso_path_by_name(name)
|
path = iso_path_by_name(name)
|
||||||
if path and path.exists() and path.is_file():
|
if path and path.exists() and path.is_file():
|
||||||
|
users = vms_using_iso(path)
|
||||||
|
if users:
|
||||||
|
return redirect_with_message("/iso", "error", f"Образ вставлен в привод машин: {', '.join(users)}. Сначала извлеките его на странице машины — иначе машина не запустится.")
|
||||||
path.unlink()
|
path.unlink()
|
||||||
refresh_iso_pool()
|
refresh_iso_pool()
|
||||||
return RedirectResponse(url="/iso", status_code=303)
|
return RedirectResponse(url="/iso", status_code=303)
|
||||||
@@ -1909,7 +1915,8 @@ async def console_websocket(websocket: WebSocket, token: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
await websocket.close(code=1008)
|
await websocket.close(code=1008)
|
||||||
return
|
return
|
||||||
if not valid_vm_name(vm_name) or not vm_exists(vm_name) or target_port < 5900 or target_port > 5999:
|
# vm_exists runs virsh: in a thread, so one slow call does not freeze every console and page.
|
||||||
|
if not valid_vm_name(vm_name) or target_port < 5900 or target_port > 5999 or not await asyncio.to_thread(vm_exists, vm_name):
|
||||||
await websocket.close(code=1008)
|
await websocket.close(code=1008)
|
||||||
return
|
return
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|||||||
+8
-1
@@ -24,6 +24,10 @@ import update_core
|
|||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
APP_NAME = "Virtuality"
|
APP_NAME = "Virtuality"
|
||||||
ENV_FILE = BASE_DIR / ".env"
|
ENV_FILE = BASE_DIR / ".env"
|
||||||
|
# virsh, ip and qemu-img print translated text under a Russian locale; the panel parses their output.
|
||||||
|
os.environ["LC_ALL"] = "C.UTF-8"
|
||||||
|
os.environ["LANG"] = "C.UTF-8"
|
||||||
|
|
||||||
STORAGE_DIR = Path("/var/lib/virtuality")
|
STORAGE_DIR = Path("/var/lib/virtuality")
|
||||||
ISO_DIR = Path("/var/lib/virtuality/iso")
|
ISO_DIR = Path("/var/lib/virtuality/iso")
|
||||||
IMAGES_DIR = Path("/var/lib/virtuality/images")
|
IMAGES_DIR = Path("/var/lib/virtuality/images")
|
||||||
@@ -199,7 +203,10 @@ def tail_text(path: Path, max_lines: int = 220) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def valid_vm_name(name: str) -> bool:
|
def valid_vm_name(name: str) -> bool:
|
||||||
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""))
|
"""Latin letters, digits, dot, dash, underscore; never a bare number or a UUID — virsh would read those as an ID/UUID of another machine."""
|
||||||
|
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""):
|
||||||
|
return False
|
||||||
|
return not name.isdigit() and not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", name)
|
||||||
|
|
||||||
|
|
||||||
def valid_label(name: str) -> bool:
|
def valid_label(name: str) -> bool:
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ def detect_host_profile() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
checks = []
|
checks = []
|
||||||
checks.append({'name': 'Аппаратное ускорение (KVM)', 'ok': data['kvm_device'], 'level': 'ok' if data['kvm_device'] else 'warn', 'hint': 'KVM недоступен. На физическом сервере включи Intel VT-x / AMD-V в BIOS/UEFI; на VPS проверь nested virtualization. Пока доступен медленный QEMU fallback.'})
|
checks.append({'name': 'Аппаратное ускорение (KVM)', 'ok': data['kvm_device'], 'level': 'ok' if data['kvm_device'] else 'warn', 'hint': 'KVM недоступен. На физическом сервере включи Intel VT-x / AMD-V в BIOS/UEFI; на VPS проверь nested virtualization. Пока доступен медленный QEMU fallback.'})
|
||||||
checks.append({'name': 'Поддержка виртуализации процессором', 'ok': virtualization_flags > 0, 'level': 'ok' if virtualization_flags > 0 else 'warn', 'hint': 'CPU-флаги vmx/svm не видны. Для реального сервера это обычно значит, что виртуализация выключена в BIOS/UEFI.'})
|
if not is_arm: # ARM CPUs have no vmx/svm flags; /dev/kvm above is what matters there
|
||||||
|
checks.append({'name': 'Поддержка виртуализации процессором', 'ok': virtualization_flags > 0, 'level': 'ok' if virtualization_flags > 0 else 'warn', 'hint': 'CPU-флаги vmx/svm не видны. Для реального сервера это обычно значит, что виртуализация выключена в BIOS/UEFI.'})
|
||||||
if is_arm:
|
if is_arm:
|
||||||
checks.append({'name': 'Эмулятор ARM64 (QEMU)', 'ok': data['qemu_system_aarch64'], 'level': 'ok' if data['qemu_system_aarch64'] else 'err', 'hint': 'Пакет qemu-system-arm / qemu-system-aarch64.'})
|
checks.append({'name': 'Эмулятор ARM64 (QEMU)', 'ok': data['qemu_system_aarch64'], 'level': 'ok' if data['qemu_system_aarch64'] else 'err', 'hint': 'Пакет qemu-system-arm / qemu-system-aarch64.'})
|
||||||
checks.append({'name': 'Загрузчик UEFI для ARM64', 'ok': data['uefi_aarch64_hint'], 'level': 'ok' if data['uefi_aarch64_hint'] else 'warn', 'hint': 'Пакет qemu-efi-aarch64 или AAVMF.'})
|
checks.append({'name': 'Загрузчик UEFI для ARM64', 'ok': data['uefi_aarch64_hint'], 'level': 'ok' if data['uefi_aarch64_hint'] else 'warn', 'hint': 'Пакет qemu-efi-aarch64 или AAVMF.'})
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ class NetworkError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def valid_vm_name(name: str) -> bool:
|
||||||
|
"""Same rule as core.valid_vm_name (kept here: this module does not import core)."""
|
||||||
|
if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', name or ''):
|
||||||
|
return False
|
||||||
|
return not name.isdigit() and not re.fullmatch(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', name)
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
|
def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
||||||
@@ -206,7 +213,7 @@ def _net_update(action: str, host_xml: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
def reserve_nat_address(vm_name: str, mac: str) -> str | None:
|
def reserve_nat_address(vm_name: str, mac: str) -> str | None:
|
||||||
"""Pin the machine to one NAT address for its whole life; None when libvirt refused."""
|
"""Pin the machine to one NAT address for its whole life; None when libvirt refused."""
|
||||||
if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name or ''):
|
if not valid_vm_name(vm_name):
|
||||||
return None
|
return None
|
||||||
reservations = nat_reservations()
|
reservations = nat_reservations()
|
||||||
for item in reservations:
|
for item in reservations:
|
||||||
@@ -360,13 +367,16 @@ def resolve_vm_ip(vm_name: str) -> str | None:
|
|||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
|
macs = set(vm_mac_addresses(vm_name))
|
||||||
|
if not macs:
|
||||||
|
# Without the machine's MAC any lease would be a guess — possibly another machine's address.
|
||||||
|
return None
|
||||||
leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8)
|
leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8)
|
||||||
if not leases['ok']:
|
if not leases['ok']:
|
||||||
return None
|
return None
|
||||||
macs = set(vm_mac_addresses(vm_name))
|
|
||||||
for line in leases['stdout'].splitlines():
|
for line in leases['stdout'].splitlines():
|
||||||
low = line.lower()
|
low = line.lower()
|
||||||
if macs and not any(mac in low for mac in macs):
|
if not any(mac in low for mac in macs):
|
||||||
continue
|
continue
|
||||||
match = re.search(r'\b(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/\d+', line)
|
match = re.search(r'\b(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/\d+', line)
|
||||||
if match:
|
if match:
|
||||||
@@ -383,7 +393,7 @@ def tcp_connect_check(host: str, port: int, timeout: float = 2.0) -> dict[str, A
|
|||||||
|
|
||||||
|
|
||||||
def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port: Any, protocol: str, note: str = '') -> dict[str, Any]:
|
def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port: Any, protocol: str, note: str = '') -> dict[str, Any]:
|
||||||
if not vm_name or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name):
|
if not valid_vm_name(vm_name):
|
||||||
raise NetworkError('Некорректное имя VM')
|
raise NetworkError('Некорректное имя VM')
|
||||||
if guest_ip == 'auto':
|
if guest_ip == 'auto':
|
||||||
resolved_ip = resolve_vm_ip(vm_name)
|
resolved_ip = resolve_vm_ip(vm_name)
|
||||||
@@ -611,7 +621,7 @@ def find_matching_forward(forwards: list[dict[str, Any]], vm_name: str, external
|
|||||||
|
|
||||||
|
|
||||||
def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, protocol: str = 'tcp') -> dict[str, Any]:
|
def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, protocol: str = 'tcp') -> dict[str, Any]:
|
||||||
if not vm_name or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name):
|
if not valid_vm_name(vm_name):
|
||||||
raise NetworkError('Некорректное имя VM')
|
raise NetworkError('Некорректное имя VM')
|
||||||
if not valid_port(external_port) or not valid_port(guest_port):
|
if not valid_port(external_port) or not valid_port(guest_port):
|
||||||
raise NetworkError('Порт должен быть от 1 до 65535')
|
raise NetworkError('Порт должен быть от 1 до 65535')
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<span class="list-icon brand">{{ icon('disc-3') }}</span>
|
<span class="list-icon brand">{{ icon('disc-3') }}</span>
|
||||||
<div class="grow"><div class="title truncate">{{ iso.name }}</div><div class="meta">{{ iso.size|replace('MB', 'МБ') }} · загружен {{ iso.updated }}</div></div>
|
<div class="grow"><div class="title truncate">{{ iso.name }}</div><div class="meta">{{ iso.size|replace('MB', 'МБ') }} · загружен {{ iso.updated }}</div></div>
|
||||||
<a class="btn btn-sm hide-sm" href="/vm/create?iso={{ iso.name|urlencode }}">Создать машину</a>
|
<a class="btn btn-sm hide-sm" href="/vm/create?iso={{ iso.name|urlencode }}">Создать машину</a>
|
||||||
<form method="post" action="/iso/{{ iso.name }}/delete" class="inline-form" data-confirm="Файл {{ iso.name }} будет удалён с сервера. Уже созданные машины продолжат работать." data-confirm-title="Удалить образ?" data-confirm-button="Удалить" data-toast="Образ удалён" data-toast-tone="warning">
|
<form method="post" action="/iso/{{ iso.name }}/delete" class="inline-form" data-confirm="Файл {{ iso.name }} будет удалён с сервера. Если образ вставлен в привод какой-то машины, панель попросит сначала извлечь его." data-confirm-title="Удалить образ?" data-confirm-button="Удалить" data-toast="Образ удалён" data-toast-tone="warning">
|
||||||
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить">{{ icon('trash-2', 'icon-sm') }}</button>
|
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить">{{ icon('trash-2', 'icon-sm') }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user