Интерфейс панели был полностью перерисован

Новый дизайн в стиле коммерческих продуктов, рассчитанный на обычного
пользователя:
- светлая и тёмная темы (следует системной), шрифт Inter и иконки
  Lucide вшиты в панель — работает без интернета;
- общий каркас base.html и компоненты _ui.html вместо 14 независимых
  страниц; адаптивная вёрстка для телефона;
- простой язык: «Работает / Выключена», «Автозапуск», «Доступ из
  интернета» вместо running, autostart, DNAT; технические данные
  убраны во вкладку «Для специалистов»;
- главная: загрузка процессора, памяти и хранилища, карточки машин с
  главным действием и меню остальных, приветственный сценарий для
  первой машины;
- мастер создания машины из трёх шагов с готовыми конфигурациями;
- страница машины с вкладками, переключателем автозапуска и порядком
  загрузки кнопками;
- «Сеть и доступ»: открытие SSH, сайта, RDP и т. д. из списка,
  ручные IP машин, понятная диагностика;
- загрузка образов перетаскиванием с прогрессом и оставшимся временем;
- подтверждения опасных действий в диалоге и уведомления;
- раздел «Помощь»;
- web/presenters.py переводит вывод virsh и /proc в данные для
  страниц; tests/dev_server.py запускает панель с имитацией virsh.

Также: исправлено определение модели CPU на x86 («207» вместо
названия процессора), история версии 0.10.0 и документация обновлены
под Ubuntu 26.04 LTS, в README добавлены скриншоты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 07:01:41 +00:00
родитель c4cebd916b
Коммит 4098c270a4
41 изменённых файлов: 3160 добавлений и 2939 удалений
+68 -18
Просмотреть файл
@@ -27,12 +27,14 @@ from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer
import auth
import host_profile
import network_core
import presenters
import update_core
from network_core import NetworkError
BASE_DIR = Path(__file__).resolve().parent
APP_NAME = "Virtuality"
ENV_FILE = BASE_DIR / ".env"
STORAGE_DIR = Path("/var/lib/virtuality")
ISO_DIR = Path("/var/lib/virtuality/iso")
IMAGES_DIR = Path("/var/lib/virtuality/images")
DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")
@@ -49,6 +51,26 @@ if NOVNC_DIR:
app.mount("/novnc", StaticFiles(directory=str(NOVNC_DIR)), name="novnc")
OP_LOCK = threading.Lock()
def update_notice() -> dict[str, Any]:
try:
data = json.loads((update_core.STATE_DIR / "last_check.json").read_text())
except Exception:
return {"has_update": False}
return {"has_update": bool(data.get("has_update")), "latest_version": data.get("latest_version", "")}
templates.env.globals.update(update_notice=update_notice, vm_presets=presenters.VM_PRESETS, port_presets=presenters.PORT_PRESETS)
templates.env.filters.update(
vm_state=presenters.vm_state,
operation_state=presenters.operation_state,
service_state=presenters.service_state,
format_mb=presenters.format_mb,
level_tone=presenters.level_tone,
port_service=presenters.port_service,
)
templates.env.globals["plural"] = presenters.plural
SECURITY_HEADERS = {
"X-Frame-Options": "SAMEORIGIN",
"X-Content-Type-Options": "nosniff",
@@ -107,6 +129,7 @@ def read_version() -> str:
APP_VERSION = read_version()
templates.env.globals["app_version"] = APP_VERSION
def redirect_with_message(path: str, key: str, message: str) -> RedirectResponse:
@@ -290,13 +313,13 @@ def start_background_operation(operation: dict[str, Any], cmd: list[str]) -> Non
LOG_SOURCES = {
"web": {"title": "Virtuality Web", "kind": "journal", "unit": "virtuality-web.service"},
"update": {"title": "Update Center", "kind": "file", "path": "/var/log/virtuality/update.log"},
"install": {"title": "Последняя установка", "kind": "glob", "pattern": "/var/log/virtuality/install_web_panel_*.log"},
"operations": {"title": "Операции", "kind": "operations"},
"auto-update": {"title": "Auto Update", "kind": "journal", "unit": "virtuality-auto-update.service"},
"libvirtd": {"title": "libvirtd", "kind": "journal", "unit": "libvirtd.service"},
"virtlogd": {"title": "virtlogd", "kind": "journal", "unit": "virtlogd.service"},
"web": {"title": "Панель", "kind": "journal", "unit": "virtuality-web.service"},
"update": {"title": "Обновления", "kind": "file", "path": "/var/log/virtuality/update.log"},
"install": {"title": "Установка", "kind": "glob", "pattern": "/var/log/virtuality/install_web_panel_*.log"},
"operations": {"title": "Задачи", "kind": "operations"},
"auto-update": {"title": "Автообновление", "kind": "journal", "unit": "virtuality-auto-update.service"},
"libvirtd": {"title": "Виртуализация", "kind": "journal", "unit": "libvirtd.service"},
"virtlogd": {"title": "Журналы машин", "kind": "journal", "unit": "virtlogd.service"},
}
@@ -459,11 +482,15 @@ def parse_virsh_list() -> list[dict[str, str]]:
for line in result["stdout"].splitlines()[2:]:
parts = line.strip().split(None, 2)
if len(parts) == 3:
autostart = vm_autostart_status(parts[1])
rows.append({"id": parts[0], "name": parts[1], "state": parts[2], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})
vm_id, name, state = parts
elif len(parts) == 2:
autostart = vm_autostart_status(parts[0])
rows.append({"id": "-", "name": parts[0], "state": parts[1], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})
vm_id, name, state = "-", parts[0], parts[1]
else:
continue
info = presenters.parse_dominfo(run_cmd(["virsh", "dominfo", name], timeout=8).get("stdout", ""))
enabled = info["autostart"]
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"]})
for row in rows:
row["ip"] = resolve_ip(row.get("name", ""))
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
@@ -1168,7 +1195,7 @@ def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]:
def vm_details(name: str) -> dict[str, Any]:
dominfo = run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"]
autostart = vm_autostart_status(name)
return {
details = {
"name": name,
"dominfo": dominfo,
"vnc": vm_vnc_display(name),
@@ -1180,6 +1207,10 @@ def vm_details(name: str) -> dict[str, Any]:
"autostart_label": autostart["label"],
"autostart_css": autostart["css"],
}
details["info"] = presenters.parse_dominfo(dominfo)
details["disk_list"] = presenters.parse_domblklist(details["disks"])
details["interface_list"] = presenters.parse_domiflist(details["interfaces"])
return details
def system_summary() -> dict[str, str]:
@@ -1241,7 +1272,7 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200):
profile = host_profile.load_host_profile()
default_mode = profile.get("recommended_network", "nat")
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "arch_options": vm_arch_options(), "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "arch_options": vm_arch_options(), "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "guest_arch": "auto", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
# Virtuality noVNC console patch
@@ -1311,7 +1342,16 @@ def dashboard(request: Request):
if auth_redirect:
return auth_redirect
services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}
return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css})
service_rows = [{"key": key, "name": presenters.SERVICE_LABELS.get(key, key), "state": state, **presenters.service_state(state)} for key, state in services.items()]
return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()})
@app.get("/help", response_class=HTMLResponse)
def help_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("help.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "host_ip": system_summary()["ip"]})
@app.get("/host", response_class=HTMLResponse)
@@ -1622,7 +1662,9 @@ def update_page(request: Request):
"state": update_core.state(),
"log_tail": update_core.update_log_tail(),
}
return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error})
current = str(info.get("current_version", ""))
current_release = next((item for item in update_core.load_manifest().get("versions", []) if str(item.get("version")) == current), None)
return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error, "current_release": current_release})
@app.get("/update/status")
@@ -1721,15 +1763,23 @@ def api_logs(request: Request, source: str = "web", lines: int = 220):
@app.get("/vm/create", response_class=HTMLResponse)
def vm_create_page(request: Request):
def vm_create_page(request: Request, iso: str = "", image: str = ""):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return vm_form_context(request)
if not iso and not image:
return vm_form_context(request)
profile = host_profile.load_host_profile()
form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "guest_arch": "auto", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE}
if iso:
form["iso_path"] = str(ISO_DIR / Path(iso).name)
if image:
form["disk_image_path"] = str(DISK_IMAGES_DIR / Path(image).name)
return vm_form_context(request, form=form)
@app.post("/vm/create", response_class=HTMLResponse)
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
+21 -16
Просмотреть файл
@@ -7,6 +7,7 @@ from typing import Any
PROFILE_FILE = Path('/var/lib/virtuality/config/host_profile.json')
DEFAULT_BRIDGE = 'br0'
PROFILE_SCHEMA = 3
def run_cmd(cmd: list[str], timeout: int = 5) -> dict[str, Any]:
@@ -34,9 +35,12 @@ def detect_board_model() -> str:
if value:
return value
cpuinfo = read_text('/proc/cpuinfo')
for line in cpuinfo.splitlines():
if line.lower().startswith(('model', 'hardware')) and ':' in line:
return line.split(':', 1)[1].strip()
for prefix in ('model name', 'hardware', 'model'):
for line in cpuinfo.splitlines():
key, _, value = line.partition(':')
value = value.strip()
if key.strip().lower() == prefix and value and not value.isdigit():
return value
return 'unknown'
@@ -70,7 +74,7 @@ def classify_profile(arch: str, model: str) -> dict[str, str]:
if 'raspberry pi' in low:
return {
'profile': 'raspberry-arm64',
'label': 'Raspberry Pi ARM64 Edge Node',
'label': 'Raspberry Pi (ARM64)',
'recommended_network': 'nat',
'recommended_guest_arch': 'aarch64',
'recommended_vm_mode': 'arm64-cloud-image',
@@ -78,21 +82,21 @@ def classify_profile(arch: str, model: str) -> dict[str, str]:
if 'orange pi 5' in low or 'orangepi 5' in low or 'rk3588' in low or 'rockchip' in low:
return {
'profile': 'orangepi5-arm64',
'label': 'Orange Pi 5 ARM64 Edge Node',
'label': 'Orange Pi 5 (ARM64)',
'recommended_network': 'nat',
'recommended_guest_arch': 'aarch64',
'recommended_vm_mode': 'arm64-cloud-image',
}
return {
'profile': 'generic-arm64',
'label': 'Generic ARM64 Edge Node',
'label': 'ARM64-сервер',
'recommended_network': 'nat',
'recommended_guest_arch': 'aarch64',
'recommended_vm_mode': 'arm64-cloud-image',
}
return {
'profile': 'x86_64',
'label': 'x86_64 KVM/QEMU Node',
'label': 'Сервер x86_64',
'recommended_network': 'bridge',
'recommended_guest_arch': 'x86_64',
'recommended_vm_mode': 'x86_64-iso',
@@ -148,17 +152,18 @@ def detect_host_profile() -> dict[str, Any]:
]),
}
checks = []
checks.append({'name': '/dev/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': 'CPU vmx/svm', 'ok': virtualization_flags > 0, 'level': 'ok' if virtualization_flags > 0 else 'warn', 'hint': 'CPU-флаги vmx/svm не видны. Для реального сервера это обычно значит, что виртуализация выключена в BIOS/UEFI.'})
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 is_arm:
checks.append({'name': 'qemu-system-aarch64', 'ok': data['qemu_system_aarch64'], 'level': 'ok' if data['qemu_system_aarch64'] else 'err', 'hint': 'Пакет qemu-system-arm / qemu-system-aarch64.'})
checks.append({'name': 'AArch64 UEFI', 'ok': data['uefi_aarch64_hint'], 'level': 'ok' if data['uefi_aarch64_hint'] else 'warn', 'hint': 'Пакет qemu-efi-aarch64 или AAVMF.'})
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.'})
else:
checks.append({'name': 'qemu-system-x86_64', 'ok': data['qemu_system_x86_64'], 'level': 'ok' if data['qemu_system_x86_64'] else 'err', 'hint': 'Пакет qemu-system-x86.'})
checks.append({'name': 'virt-install', 'ok': data['virt_install'], 'level': 'ok' if data['virt_install'] else 'err', 'hint': 'Пакет virtinst.'})
checks.append({'name': 'nftables', 'ok': data['nft'], 'level': 'ok' if data['nft'] else 'err', 'hint': 'Пакет nftables для NAT port forwarding.'})
checks.append({'name': f'bridge {DEFAULT_BRIDGE}', 'ok': bridge_available, 'level': 'ok' if bridge_available else 'warn', 'hint': 'Если br0 отсутствует, Virtuality будет использовать VPS NAT Router / virtuality-nat.'})
checks.append({'name': 'Эмулятор x86_64 (QEMU)', 'ok': data['qemu_system_x86_64'], 'level': 'ok' if data['qemu_system_x86_64'] else 'err', 'hint': 'Пакет qemu-system-x86.'})
checks.append({'name': 'Создание машин (virt-install)', 'ok': data['virt_install'], 'level': 'ok' if data['virt_install'] else 'err', 'hint': 'Пакет virtinst.'})
checks.append({'name': 'Брандмауэр nftables', 'ok': data['nft'], 'level': 'ok' if data['nft'] else 'err', 'hint': 'Пакет nftables для NAT port forwarding.'})
checks.append({'name': f'Сетевой мост {DEFAULT_BRIDGE}', 'ok': bridge_available, 'level': 'ok' if bridge_available else 'warn', 'hint': 'Если br0 отсутствует, Virtuality будет использовать VPS NAT Router / virtuality-nat.'})
data['checks'] = checks
data['schema'] = PROFILE_SCHEMA
data['ready'] = data['virt_install'] and (data['qemu_system_aarch64'] if is_arm else data['qemu_system_x86_64']) and data['nft']
return data
@@ -172,7 +177,7 @@ def load_host_profile() -> dict[str, Any]:
if PROFILE_FILE.exists():
try:
profile = json.loads(PROFILE_FILE.read_text())
if 'bridge_available' not in profile or 'virtualization_mode' not in profile or any('level' not in item for item in profile.get('checks', [])):
if profile.get('schema') != PROFILE_SCHEMA or 'bridge_available' not in profile or 'virtualization_mode' not in profile or any('level' not in item for item in profile.get('checks', [])):
profile = detect_host_profile()
save_host_profile(profile)
return profile
+244
Просмотреть файл
@@ -0,0 +1,244 @@
"""View helpers: turn virsh/system output into plain-language data for templates."""
import os
import re
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any
VM_STATES = {
"running": ("Работает", "success"),
"idle": ("Работает", "success"),
"blocked": ("Работает", "success"),
"paused": ("Приостановлена", "warning"),
"pmsuspended": ("Спящий режим", "warning"),
"in shutdown": ("Выключается", "warning"),
"shut off": ("Выключена", "neutral"),
"shutoff": ("Выключена", "neutral"),
"crashed": ("Сбой", "danger"),
"dying": ("Останавливается", "warning"),
}
OPERATION_STATES = {
"success": ("Готово", "success"),
"error": ("Ошибка", "danger"),
"running": ("Выполняется", "info"),
"queued": ("В очереди", "neutral"),
}
SERVICE_LABELS = {
"libvirtd": "Виртуализация",
"virtlogd": "Журналы машин",
"cockpit": "Cockpit",
"dashboard": "Экран сервера",
"web": "Панель управления",
}
PORT_PRESETS = [
{"port": "22", "label": "SSH — удалённый доступ к Linux", "protocol": "tcp"},
{"port": "80", "label": "Сайт (HTTP)", "protocol": "tcp"},
{"port": "443", "label": "Сайт (HTTPS)", "protocol": "tcp"},
{"port": "3389", "label": "Удалённый рабочий стол Windows (RDP)", "protocol": "tcp"},
{"port": "25565", "label": "Minecraft", "protocol": "tcp"},
{"port": "3306", "label": "MySQL / MariaDB", "protocol": "tcp"},
{"port": "5432", "label": "PostgreSQL", "protocol": "tcp"},
{"port": "1194", "label": "OpenVPN", "protocol": "udp"},
{"port": "51820", "label": "WireGuard VPN", "protocol": "udp"},
{"port": "5060", "label": "SIP-телефония", "protocol": "udp"},
{"port": "10000-20000", "label": "Голос VoIP (RTP)", "protocol": "udp"},
]
WELL_KNOWN_PORTS = {int(item["port"]): item["label"].split(" (")[0].split(" — ")[0] for item in PORT_PRESETS if item["port"].isdigit()}
VM_PRESETS = [
{"id": "mini", "title": "Мини", "hint": "Роутер, VPN, лёгкий сервис", "vcpus": 1, "memory": 1024, "disk_size": 10},
{"id": "standard", "title": "Стандарт", "hint": "Сайт, база данных, Linux-сервер", "vcpus": 2, "memory": 4096, "disk_size": 40},
{"id": "power", "title": "Мощная", "hint": "Windows, 1С, игровой сервер", "vcpus": 4, "memory": 8192, "disk_size": 80},
]
def vm_state(state: str) -> dict[str, Any]:
key = (state or "").strip().lower()
label, tone = VM_STATES.get(key, (state or "Неизвестно", "neutral"))
return {"label": label, "tone": tone, "running": tone == "success", "raw": state}
def operation_state(status: str) -> dict[str, str]:
label, tone = OPERATION_STATES.get((status or "").lower(), (status or "—", "neutral"))
return {"label": label, "tone": tone}
def service_state(state: str) -> dict[str, str]:
if state == "active":
return {"label": "Работает", "tone": "success"}
if state in ("activating", "reloading"):
return {"label": "Запускается", "tone": "warning"}
if state == "failed":
return {"label": "Сбой", "tone": "danger"}
return {"label": "Остановлена", "tone": "neutral"}
def _kib_to_mb(value: str) -> int:
match = re.search(r"(\d+)", value or "")
if not match:
return 0
amount = int(match.group(1))
low = (value or "").lower()
if "mib" in low:
return amount
if "gib" in low:
return amount * 1024
return amount // 1024
def parse_dominfo(text: str) -> dict[str, Any]:
fields: dict[str, str] = {}
for line in (text or "").splitlines():
if ":" in line:
key, value = line.split(":", 1)
fields[key.strip().lower()] = value.strip()
try:
vcpus = int(fields.get("cpu(s)", "0") or 0)
except ValueError:
vcpus = 0
autostart = fields.get("autostart", "").lower()
return {
"state": fields.get("state", ""),
"vcpus": vcpus,
"memory_mb": _kib_to_mb(fields.get("used memory", "")) or _kib_to_mb(fields.get("max memory", "")),
"max_memory_mb": _kib_to_mb(fields.get("max memory", "")),
"autostart": autostart in ("enable", "enabled", "yes", "on"),
"autostart_known": bool(autostart),
"persistent": fields.get("persistent", "") == "yes",
"os_type": fields.get("os type", ""),
"uuid": fields.get("uuid", ""),
}
def _table_rows(text: str) -> list[list[str]]:
rows = []
for line in (text or "").splitlines():
stripped = line.strip()
if not stripped or set(stripped) <= {"-", " "}:
continue
rows.append(stripped.split())
return rows[1:] if rows else []
def parse_domblklist(text: str) -> list[dict[str, str]]:
disks = []
for parts in _table_rows(text):
if len(parts) >= 4:
kind, device, target, source = parts[0], parts[1], parts[2], " ".join(parts[3:])
elif len(parts) >= 2:
kind, device, target, source = "file", "disk", parts[0], " ".join(parts[1:])
else:
continue
if source == "-":
source = ""
disks.append({
"type": kind,
"device": device,
"target": target,
"source": source,
"name": Path(source).name if source else "Пусто",
"is_cdrom": device == "cdrom",
"label": "Привод CD/DVD" if device == "cdrom" else "Диск",
})
return disks
def parse_domiflist(text: str) -> list[dict[str, str]]:
interfaces = []
for parts in _table_rows(text):
if len(parts) < 2:
continue
padded = parts + ["—"] * (5 - len(parts))
interfaces.append({"interface": padded[0], "type": padded[1], "source": padded[2], "model": padded[3], "mac": padded[4]})
return interfaces
def format_mb(value: int | float) -> str:
value = float(value or 0)
if value >= 1024:
gb = value / 1024
return f"{gb:.0f} ГБ" if gb >= 10 or gb.is_integer() else f"{gb:.1f} ГБ"
return f"{value:.0f} МБ"
def format_gb(value: float) -> str:
if value >= 1024:
return f"{value / 1024:.1f} ТБ"
return f"{value:.0f} ГБ" if value >= 10 else f"{value:.1f} ГБ"
def _meminfo() -> dict[str, int]:
data = {}
try:
for line in Path("/proc/meminfo").read_text().splitlines():
key, value = line.split(":", 1)
data[key] = int(value.split()[0])
except (OSError, ValueError):
pass
return data
def host_stats(storage_path: Path) -> dict[str, Any]:
cpu_count = os.cpu_count() or 1
try:
load1 = float(Path("/proc/loadavg").read_text().split()[0])
except (OSError, ValueError, IndexError):
load1 = 0.0
mem = _meminfo()
mem_total = mem.get("MemTotal", 0) / 1024 / 1024
mem_used = max(0.0, mem_total - mem.get("MemAvailable", 0) / 1024 / 1024)
path = storage_path if storage_path.exists() else Path("/")
try:
usage = shutil.disk_usage(path)
disk_total, disk_used, disk_free = usage.total / 1024 ** 3, usage.used / 1024 ** 3, usage.free / 1024 ** 3
except OSError:
disk_total = disk_used = disk_free = 0.0
def pct(used: float, total: float) -> int:
return int(round(min(100.0, max(0.0, used / total * 100)))) if total else 0
return {
"cpu_count": cpu_count,
"cpu_pct": pct(load1, cpu_count),
"load1": load1,
"mem_total": format_gb(mem_total),
"mem_used": format_gb(mem_used),
"mem_pct": pct(mem_used, mem_total),
"disk_total": format_gb(disk_total),
"disk_used": format_gb(disk_used),
"disk_free": format_gb(disk_free),
"disk_pct": pct(disk_used, disk_total),
}
def level_tone(pct: int) -> str:
return "danger" if pct >= 90 else "warning" if pct >= 75 else "success"
def greeting(now: datetime | None = None) -> str:
hour = (now or datetime.now()).hour
if 5 <= hour < 12:
return "Доброе утро"
if 12 <= hour < 18:
return "Добрый день"
if 18 <= hour < 23:
return "Добрый вечер"
return "Доброй ночи"
def plural(count: int, one: str, few: str, many: str) -> str:
count = abs(int(count))
if count % 10 == 1 and count % 100 != 11:
return one
if 2 <= count % 10 <= 4 and not 12 <= count % 100 <= 14:
return few
return many
def port_service(port: int) -> str:
return WELL_KNOWN_PORTS.get(int(port or 0), "")
+43
Просмотреть файл
@@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+558 -292
Просмотреть файл
@@ -1,320 +1,586 @@
:root {
--bg: #070b14;
--bg-soft: #0b1220;
--panel: rgba(15, 23, 42, .86);
--panel-solid: #111827;
--panel-2: rgba(30, 41, 59, .72);
--line: rgba(148, 163, 184, .18);
--line-strong: rgba(148, 163, 184, .32);
--text: #e5edf8;
--muted: #91a0b8;
--muted-2: #64748b;
--accent: #38bdf8;
--accent-2: #2563eb;
--accent-glow: rgba(56, 189, 248, .22);
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
--purple: #a78bfa;
--radius: 18px;
--radius-sm: 12px;
--shadow: 0 22px 60px rgba(0, 0, 0, .32);
--shadow-soft: 0 12px 34px rgba(0, 0, 0, .22);
/* ==========================================================
Virtuality design system
Light theme by default, dark via [data-theme="dark"] or system.
========================================================== */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/static/fonts/inter-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/static/fonts/inter-cyrillic.woff2") format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
* { box-sizing: border-box; }
html { min-height: 100%; background: var(--bg); }
:root {
color-scheme: light;
--font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace;
--bg: #f5f6fa;
--surface: #ffffff;
--surface-2: #fafbfc;
--surface-3: #f1f3f7;
--border: #e5e7ee;
--border-strong: #d3d7e1;
--text: #0e1525;
--text-2: #4a5468;
--text-3: #7b8498;
--brand: #5a48f5;
--brand-hover: #4a38e6;
--brand-soft: #efedff;
--brand-text: #4533d8;
--on-brand: #ffffff;
--focus: rgba(90, 72, 245, .28);
--success: #0f9f5a; --success-soft: #e7f7ee;
--warning: #c77c02; --warning-soft: #fff4dd;
--danger: #e03e45; --danger-soft: #fdecec;
--info: #2f73f0; --info-soft: #e9f1ff;
--neutral: #6b7488; --neutral-soft: #eff1f5;
--terminal-bg: #0d1117;
--terminal-text: #d6deeb;
--shadow-xs: 0 1px 2px rgba(16, 24, 40, .05);
--shadow-sm: 0 1px 3px rgba(16, 24, 40, .06), 0 1px 2px rgba(16, 24, 40, .04);
--shadow-md: 0 8px 24px -6px rgba(16, 24, 40, .10), 0 2px 6px -2px rgba(16, 24, 40, .05);
--shadow-lg: 0 24px 48px -12px rgba(16, 24, 40, .22);
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
--radius-xl: 22px;
--sidebar-w: 256px;
--content-max: 1280px;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #0a0c11;
--surface: #11141b;
--surface-2: #151922;
--surface-3: #1b2029;
--border: #232935;
--border-strong: #303746;
--text: #edf0f6;
--text-2: #a9b1c2;
--text-3: #7a8395;
--brand: #8576ff;
--brand-hover: #9587ff;
--brand-soft: rgba(133, 118, 255, .14);
--brand-text: #aea4ff;
--focus: rgba(133, 118, 255, .35);
--success: #34c77b; --success-soft: rgba(52, 199, 123, .13);
--warning: #f0a92b; --warning-soft: rgba(240, 169, 43, .13);
--danger: #ff6369; --danger-soft: rgba(255, 99, 105, .13);
--info: #5b9bff; --info-soft: rgba(91, 155, 255, .13);
--neutral: #8d96a8; --neutral-soft: rgba(141, 150, 168, .13);
--terminal-bg: #07090d;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, .3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, .35);
--shadow-md: 0 10px 28px -8px rgba(0, 0, 0, .55);
--shadow-lg: 0 28px 56px -12px rgba(0, 0, 0, .7);
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
background:
radial-gradient(circle at 18% 10%, rgba(56, 189, 248, .18), transparent 32%),
radial-gradient(circle at 80% 4%, rgba(167, 139, 250, .16), transparent 30%),
linear-gradient(135deg, #060913 0%, #0b1120 42%, #08111d 100%);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, rgba(0,0,0,.72), transparent 80%);
font: 14px/1.5 var(--font);
font-feature-settings: "cv11", "ss01";
-webkit-font-smoothing: antialiased;
}
a { color: var(--brand-text); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 3px; }
h1, h2, h3, h4 { margin: 0; color: var(--text); letter-spacing: -.015em; }
h1 { font-size: 26px; line-height: 1.2; font-weight: 700; letter-spacing: -.025em; }
h2 { font-size: 16px; line-height: 1.35; font-weight: 650; }
h3 { font-size: 14px; font-weight: 650; }
p { margin: 0; }
code, pre, .mono { font-family: var(--mono); }
code { font-size: 12.5px; padding: 1px 6px; border-radius: 6px; background: var(--surface-3); color: var(--text); }
hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; }
[hidden] { display: none !important; }
::selection { background: var(--focus); }
a { color: inherit; }
button, input, select { font: inherit; }
.icon { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
.icon-sm { width: 15px; height: 15px; }
.icon-lg { width: 22px; height: 22px; }
.icon-xl { width: 28px; height: 28px; }
.v-layout { display: grid; grid-template-columns: 272px minmax(0, 1fr); min-height: 100vh; }
.v-sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 18px 14px;
z-index: 20;
color: var(--text);
background: linear-gradient(180deg, rgba(15, 23, 42, .96), rgba(2, 6, 23, .94));
border-right: 1px solid var(--line);
box-shadow: 18px 0 60px rgba(0,0,0,.22);
backdrop-filter: blur(16px);
/* ---------------------------------------------------------- layout */
.app { display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); min-height: 100vh; background: linear-gradient(90deg, var(--surface) calc(var(--sidebar-w) - 1px), var(--border) calc(var(--sidebar-w) - 1px) var(--sidebar-w), transparent var(--sidebar-w)); }
.sidebar {
position: sticky; top: 0; height: 100vh;
display: flex; flex-direction: column; gap: 4px;
padding: 18px 14px 14px;
background: var(--surface);
border-right: 1px solid var(--border);
overflow-y: auto;
}
.v-logo { display: grid; grid-template-columns: 42px 1fr; align-items: center; gap: 12px; padding: 12px 10px 18px; margin-bottom: 12px; border-bottom: 1px solid var(--line); }
.v-logo::before { content: "V"; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 14px; color: #00111f; font-weight: 1000; background: linear-gradient(135deg, var(--accent), var(--purple)); box-shadow: 0 0 28px var(--accent-glow); }
.v-logo strong { font-size: 20px; letter-spacing: -.04em; line-height: 1; }
.v-logo span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; }
.v-nav { display: grid; gap: 7px; min-height: calc(100vh - 118px); align-content: start; }
.v-nav a, .v-nav-group-title {
position: relative;
display: flex;
align-items: center;
min-height: 42px;
gap: 9px;
width: 100%;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 14px;
color: #c7d2e4;
background: transparent;
text-decoration: none;
font-size: 14px;
font-weight: 800;
cursor: pointer;
transition: transform .16s ease, background .16s ease, border-color .16s ease, color .16s ease;
.logo { display: flex; align-items: center; gap: 11px; padding: 4px 8px 18px; color: var(--text); }
.logo:hover { text-decoration: none; }
.brand-mark {
display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px;
background: linear-gradient(135deg, #7b6bff 0%, #5a48f5 45%, #2f73f0 100%);
box-shadow: 0 6px 16px -6px rgba(90, 72, 245, .7), inset 0 1px 0 rgba(255,255,255,.25);
color: #fff;
}
.v-nav a:hover, .v-nav-group-title:hover { color: #fff; background: rgba(255,255,255,.06); border-color: var(--line); transform: translateX(2px); }
.v-nav a.active { color: #fff; background: linear-gradient(135deg, rgba(56,189,248,.18), rgba(37,99,235,.16)); border-color: rgba(56,189,248,.36); box-shadow: inset 0 0 0 1px rgba(255,255,255,.03), 0 10px 26px rgba(56,189,248,.08); }
.v-nav a.active::before { content: ""; position: absolute; left: -8px; top: 12px; bottom: 12px; width: 4px; border-radius: 99px; background: var(--accent); box-shadow: 0 0 20px var(--accent); }
.v-nav a[data-icon]::after { content: attr(data-icon); margin-left: auto; opacity: .68; font-size: 15px; }
.v-nav-bottom { margin-top: auto; border-top: 1px solid var(--line) !important; }
.v-nav-group { display: grid; gap: 6px; padding: 7px; border-radius: 16px; border: 1px solid rgba(148,163,184,.12); background: rgba(15,23,42,.48); }
.v-nav-group-title { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .1em; }
.v-nav-group-title::after { content: "▾"; margin-left: auto; opacity: .8; }
.v-nav-group:not(.open) a { display: none; }
.v-nav-group:not(.open) .v-nav-group-title::after { content: "▸"; }
.v-nav-group a { min-height: 38px; padding-left: 16px; font-size: 13px; }
.v-nav-group.open { border-color: rgba(56,189,248,.24); background: rgba(56,189,248,.07); }
.brand-mark svg { width: 20px; height: 20px; }
.brand-name { font-size: 16px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
.brand-name small { display: block; margin-top: 2px; color: var(--text-3); font-size: 11.5px; font-weight: 500; letter-spacing: 0; }
.v-main { position: relative; min-width: 0; padding: 22px 24px 48px; }
.v-topbar, .topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding: 18px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(135deg, rgba(15,23,42,.88), rgba(30,41,59,.68));
box-shadow: var(--shadow-soft);
backdrop-filter: blur(18px);
.nav { display: grid; gap: 2px; }
.nav-section { margin: 14px 10px 6px; color: var(--text-3); font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
.nav a {
display: flex; align-items: center; gap: 11px; padding: 8px 10px; border-radius: 9px;
color: var(--text-2); font-weight: 500; transition: background .15s, color .15s;
}
.v-title, .brand { font-size: clamp(24px, 2.4vw, 34px); line-height: 1; font-weight: 950; letter-spacing: -.055em; }
.v-subtitle { margin-top: 8px; color: var(--muted); font-size: 14px; }
.top-meta { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.top-meta span, .pill, .link-pill {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 7px 11px;
border: 1px solid var(--line);
border-radius: 999px;
color: #cbd5e1;
background: rgba(15,23,42,.62);
font-size: 13px;
font-weight: 800;
text-decoration: none;
white-space: nowrap;
}
.link-pill:hover { border-color: rgba(56,189,248,.5); color: #fff; background: rgba(56,189,248,.12); }
.link-pill.accent, button.primary, .icon-action.accent { color: #00111f; border-color: transparent; background: linear-gradient(135deg, var(--accent), #60a5fa); box-shadow: 0 12px 32px rgba(56,189,248,.2); }
.link-pill.accent:hover, button.primary:hover, .icon-action.accent:hover { color: #00111f; filter: brightness(1.08); transform: translateY(-1px); }
.nav a:hover { background: var(--surface-3); color: var(--text); text-decoration: none; }
.nav a.active { background: var(--brand-soft); color: var(--brand-text); font-weight: 600; }
.nav a .icon { color: var(--text-3); }
.nav a.active .icon, .nav a:hover .icon { color: inherit; }
.nav a .nav-count { margin-left: auto; padding: 0 7px; border-radius: 999px; background: var(--surface-3); color: var(--text-2); font-size: 11.5px; font-weight: 600; }
.nav a .nav-dot { margin-left: auto; width: 8px; height: 8px; border-radius: 50%; background: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
.grid { display: grid; gap: 14px; }
.stats, .compact-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 14px; }
.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 14px; }
.three { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 14px; }
.card, .auth-card {
position: relative;
overflow: hidden;
margin-bottom: 14px;
padding: 18px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(180deg, rgba(15,23,42,.88), rgba(15,23,42,.68));
box-shadow: var(--shadow-soft);
backdrop-filter: blur(16px);
}
.card::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,.28), transparent); opacity: .7; }
.card.stat { min-height: 112px; display: grid; align-content: space-between; }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
h2 { margin: 0; font-size: 18px; letter-spacing: -.02em; }
.label { color: var(--muted); font-size: 11px; margin-bottom: 7px; text-transform: uppercase; letter-spacing: .12em; font-weight: 900; }
.value { font-size: clamp(24px, 2.7vw, 34px); font-weight: 950; letter-spacing: -.06em; }
.value.small { font-size: 16px; letter-spacing: -.02em; }
.muted { color: var(--muted); }
.small-note { font-size: 13px; line-height: 1.5; }
.strong { font-weight: 900; }
.top-space { margin-top: 14px; }
.inline-form, .logout-form, .actions form, .compact-actions form { margin: 0; }
.sidebar-footer { margin-top: auto; display: grid; gap: 10px; padding-top: 14px; }
.server-chip { display: flex; align-items: center; gap: 10px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }
.server-chip .avatar { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--brand-soft); color: var(--brand-text); font-weight: 700; }
.server-chip div { min-width: 0; flex: 1; }
.server-chip strong { display: block; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.server-chip span { display: block; color: var(--text-3); font-size: 12px; }
.sidebar-actions { display: flex; gap: 6px; }
.sidebar-actions form { display: contents; }
.status { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; border-radius: 999px; font-size: 12px; font-weight: 950; border: 1px solid currentColor; white-space: nowrap; }
.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 14px currentColor; }
.status.ok { color: var(--green); background: rgba(34,197,94,.09); }
.status.warn { color: var(--yellow); background: rgba(245,158,11,.10); }
.status.err { color: var(--red); background: rgba(239,68,68,.10); }
.status-button { cursor: pointer; }
.main { min-width: 0; display: flex; flex-direction: column; }
.content { width: 100%; max-width: var(--content-max); margin: 0 auto; padding: 32px 36px 56px; }
.mobilebar { display: none; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 26px; flex-wrap: wrap; }
.page-header .eyebrow { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; color: var(--text-3); font-size: 13px; font-weight: 500; }
.page-header .eyebrow a { color: var(--text-3); }
.page-header .eyebrow a:hover { color: var(--text); }
.page-header p.lead { margin-top: 6px; color: var(--text-2); font-size: 14.5px; max-width: 720px; }
.page-header .title-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.page-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.stack { display: grid; gap: 20px; }
.stack-sm { display: grid; gap: 12px; }
.stack-xs { display: grid; gap: 6px; }
.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.grid { display: grid; gap: 20px; }
.grid > *, .stack > * { min-width: 0; }
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-main { grid-template-columns: minmax(0, 1fr) 360px; align-items: start; }
.span-2 { grid-column: span 2; }
.muted { color: var(--text-2); }
.subtle { color: var(--text-3); }
.small { font-size: 12.5px; }
.strong { font-weight: 600; color: var(--text); }
.nowrap { white-space: nowrap; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mt-0 { margin-top: 0; } .mt-1 { margin-top: 6px; } .mt-2 { margin-top: 12px; } .mt-3 { margin-top: 20px; } .mt-4 { margin-top: 28px; }
/* ---------------------------------------------------------- cards */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-xs); }
.card-body { padding: 22px; }
.card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px; border-bottom: 1px solid var(--border); }
.card-header p { margin-top: 2px; color: var(--text-3); font-size: 13px; }
.card-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 14px 22px; border-top: 1px solid var(--border); background: var(--surface-2); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
.card-link { display: block; color: inherit; transition: border-color .15s, box-shadow .15s, transform .15s; }
.card-link:hover { text-decoration: none; border-color: var(--border-strong); box-shadow: var(--shadow-md); }
.section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 12px; }
.section-title h2 { font-size: 15px; }
/* stat cards */
.stat { padding: 18px 20px; }
.stat-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--text-2); font-size: 13px; font-weight: 500; }
.stat-icon { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--surface-3); color: var(--text-2); }
.stat-value { margin-top: 12px; font-size: 26px; font-weight: 700; letter-spacing: -.03em; line-height: 1.1; }
.stat-value small { margin-left: 3px; color: var(--text-3); font-size: 14px; font-weight: 500; letter-spacing: 0; }
.stat-foot { margin-top: 12px; color: var(--text-3); font-size: 12.5px; }
.meter { height: 6px; margin-top: 14px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
.meter > span { display: block; height: 100%; width: var(--value, 0%); border-radius: inherit; background: var(--success); transition: width .6s cubic-bezier(.2, .8, .2, 1); }
.meter.warning > span { background: var(--warning); }
.meter.danger > span { background: var(--danger); }
.meter.brand > span { background: linear-gradient(90deg, var(--brand), #2f73f0); }
.meter.lg { height: 10px; }
/* ---------------------------------------------------------- badges */
.badge {
display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px;
border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap;
background: var(--neutral-soft); color: var(--neutral);
}
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.badge.success { background: var(--success-soft); color: var(--success); }
.badge.warning { background: var(--warning-soft); color: var(--warning); }
.badge.danger { background: var(--danger-soft); color: var(--danger); }
.badge.info { background: var(--info-soft); color: var(--info); }
.badge.brand { background: var(--brand-soft); color: var(--brand-text); }
.badge.success .dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 22%, transparent); animation: pulse 2.4s ease-in-out infinite; }
.badge.info .dot { animation: pulse 1.4s ease-in-out infinite; }
.badge.lg { height: 28px; padding: 0 11px; font-size: 13px; }
@keyframes pulse { 50% { opacity: .45; } }
.tag { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 6px; background: var(--surface-3); color: var(--text-2); font-size: 12px; font-weight: 500; }
/* ---------------------------------------------------------- buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 38px; padding: 0 15px; border: 1px solid var(--border-strong); border-radius: 10px;
background: var(--surface); color: var(--text); box-shadow: var(--shadow-xs);
font: 600 13.5px/1 var(--font); cursor: pointer; white-space: nowrap;
transition: background .15s, border-color .15s, box-shadow .15s, color .15s, transform .05s;
}
.btn:hover { background: var(--surface-3); text-decoration: none; }
.btn:active { transform: translateY(1px); }
.btn:focus-visible, .input:focus-visible, .select:focus-visible, textarea:focus-visible { outline: none; box-shadow: 0 0 0 4px var(--focus); }
.btn[disabled], .btn.disabled { opacity: .5; cursor: not-allowed; pointer-events: none; }
.btn-primary { border-color: transparent; background: var(--brand); color: var(--on-brand); box-shadow: 0 1px 2px rgba(16,24,40,.1), inset 0 1px 0 rgba(255,255,255,.14); }
.btn-primary:hover { background: var(--brand-hover); }
.btn-success { border-color: transparent; background: var(--success); color: #fff; }
.btn-success:hover { filter: brightness(1.06); background: var(--success); }
.btn-danger { border-color: transparent; background: var(--danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.06); background: var(--danger); }
.btn-danger-soft { border-color: transparent; background: var(--danger-soft); color: var(--danger); box-shadow: none; }
.btn-danger-soft:hover { background: var(--danger); color: #fff; }
.btn-ghost { border-color: transparent; background: transparent; box-shadow: none; color: var(--text-2); }
.btn-ghost:hover { background: var(--surface-3); color: var(--text); }
.btn-sm { height: 32px; padding: 0 11px; font-size: 13px; border-radius: 8px; }
.btn-lg { height: 46px; padding: 0 20px; font-size: 14.5px; border-radius: 12px; }
.btn-block { width: 100%; }
.btn-icon { width: 38px; padding: 0; }
.btn-icon.btn-sm { width: 32px; }
.btn .spinner { width: 15px; height: 15px; border-width: 2px; }
.inline-form { display: inline-flex; margin: 0; }
/* ---------------------------------------------------------- forms */
.field { display: grid; gap: 7px; }
.field > label, .label { color: var(--text); font-size: 13px; font-weight: 600; }
.field .hint { color: var(--text-3); font-size: 12.5px; }
.input, .select, textarea.input {
width: 100%; height: 40px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 10px;
background: var(--surface); color: var(--text); font: 400 14px/1.4 var(--font);
box-shadow: var(--shadow-xs); transition: border-color .15s, box-shadow .15s;
}
.input::placeholder { color: var(--text-3); }
.input:focus, .select:focus { border-color: var(--brand); outline: none; box-shadow: 0 0 0 4px var(--focus); }
.input[disabled], .select[disabled] { background: var(--surface-3); color: var(--text-3); cursor: not-allowed; }
.select { appearance: none; padding-right: 36px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%237b8498' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 24 24'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 11px center; }
.input-group { display: flex; align-items: stretch; }
.input-group .input { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.input-group .addon { display: grid; place-items: center; padding: 0 12px; border: 1px solid var(--border-strong); border-left: 0; border-radius: 0 10px 10px 0; background: var(--surface-3); color: var(--text-2); font-size: 13px; font-weight: 500; }
.form-grid { display: grid; gap: 16px; }
.form-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.form-row-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
/* switch */
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.switch-row .switch-text strong { display: block; font-weight: 600; }
.switch-row .switch-text span { color: var(--text-3); font-size: 12.5px; }
.switch { position: relative; width: 42px; height: 24px; flex: none; padding: 0; border: 0; border-radius: 999px; background: var(--border-strong); cursor: pointer; transition: background .2s; }
.switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(16,24,40,.25); transition: transform .2s cubic-bezier(.2,.8,.2,1); }
.switch[aria-checked="true"] { background: var(--success); }
.switch[aria-checked="true"]::after { transform: translateX(18px); }
/* option cards (radio) */
.options { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.options-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 1400px) { .options-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.option { position: relative; display: block; cursor: pointer; }
.option input { position: absolute; opacity: 0; pointer-events: none; }
.option-body {
display: grid; gap: 6px; height: 100%; padding: 16px; border: 1.5px solid var(--border); border-radius: var(--radius);
background: var(--surface); transition: border-color .15s, box-shadow .15s, background .15s;
}
.option:hover .option-body { border-color: var(--border-strong); }
.option input:checked + .option-body { border-color: var(--brand); background: color-mix(in srgb, var(--brand-soft) 55%, var(--surface)); box-shadow: 0 0 0 4px var(--focus); }
.option input:focus-visible + .option-body { box-shadow: 0 0 0 4px var(--focus); }
.option input:disabled + .option-body { opacity: .5; cursor: not-allowed; }
.option-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.option-icon { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: var(--surface-3); color: var(--text-2); }
.option input:checked + .option-body .option-icon { background: var(--brand); color: #fff; }
.option-check { width: 18px; height: 18px; border: 1.5px solid var(--border-strong); border-radius: 50%; transition: all .15s; }
.option input:checked + .option-body .option-check { border: 5px solid var(--brand); }
.option-title { font-weight: 650; font-size: 14px; }
.option-text { color: var(--text-2); font-size: 12.5px; }
.option-specs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
/* segmented */
.segmented { justify-self: start; display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: var(--surface-3); }
.segmented a, .segmented button { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: var(--text-2); font: 600 13px/1 var(--font); cursor: pointer; }
.segmented a:hover, .segmented button:hover { color: var(--text); text-decoration: none; }
.segmented .active, .segmented [aria-selected="true"] { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
/* tabs */
.tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid var(--border); overflow-x: auto; }
.tabs a, .tabs button { display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; background: none; color: var(--text-2); font: 600 13.5px/1 var(--font); cursor: pointer; white-space: nowrap; }
.tabs a:hover, .tabs button:hover { color: var(--text); text-decoration: none; }
.tabs .active, .tabs [aria-selected="true"] { color: var(--brand-text); border-bottom-color: var(--brand); }
/* ---------------------------------------------------------- lists & tables */
.list { display: grid; }
.list-item { display: flex; align-items: center; gap: 14px; padding: 14px 22px; border-top: 1px solid var(--border); }
.list-item:first-child { border-top: 0; }
a.list-item { color: inherit; }
a.list-item:hover { background: var(--surface-2); text-decoration: none; }
.list-item .grow { flex: 1; min-width: 0; }
.list-item .title { font-weight: 600; }
.list-item .meta { color: var(--text-3); font-size: 12.5px; }
.list-icon { display: grid; place-items: center; width: 36px; height: 36px; flex: none; border-radius: 10px; background: var(--surface-3); color: var(--text-2); }
.list-icon.success { background: var(--success-soft); color: var(--success); }
.list-icon.danger { background: var(--danger-soft); color: var(--danger); }
.list-icon.info { background: var(--info-soft); color: var(--info); }
.list-icon.brand { background: var(--brand-soft); color: var(--brand-text); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: separate; border-spacing: 0 8px; }
th { padding: 0 12px 6px; color: var(--muted); font-size: 11px; font-weight: 950; text-transform: uppercase; letter-spacing: .12em; text-align: left; }
td { padding: 12px; vertical-align: middle; background: rgba(15,23,42,.52); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
td:first-child { border-left: 1px solid var(--line); border-radius: 14px 0 0 14px; }
td:last-child { border-right: 1px solid var(--line); border-radius: 0 14px 14px 0; }
tbody tr:hover td { background: rgba(30,41,59,.86); border-color: rgba(56,189,248,.28); }
.table-link { color: #e0f2fe; text-decoration: none; font-weight: 950; }
.table-link:hover { color: var(--accent); }
.vm-table th:last-child, .vm-table td:last-child { text-align: right; }
.vm-actions-cell { min-width: 280px; }
.table { width: 100%; border-collapse: collapse; }
.table th { padding: 10px 22px; border-bottom: 1px solid var(--border); background: var(--surface-2); color: var(--text-3); font-size: 12px; font-weight: 600; text-align: left; white-space: nowrap; }
.table td { padding: 14px 22px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.table tr:last-child td { border-bottom: 0; }
.table tbody tr:hover td { background: var(--surface-2); }
.table .actions { display: flex; justify-content: flex-end; gap: 6px; }
.actions, .compact-actions, .big-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.compact-actions { justify-content: flex-end; }
button, .icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 36px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 12px;
color: var(--text);
background: rgba(15,23,42,.72);
text-decoration: none;
font-size: 13px;
font-weight: 900;
cursor: pointer;
transition: transform .16s ease, border-color .16s ease, background .16s ease, filter .16s ease;
.kv { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface); }
.kv > div { padding: 12px 14px; box-shadow: 1px 0 0 var(--border), 0 1px 0 var(--border); }
.kv > div > span { display: block; color: var(--text-3); font-size: 12px; font-weight: 500; }
.kv > div > strong { display: block; margin-top: 3px; font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
.checklist { display: grid; }
.check-item { display: flex; gap: 12px; padding: 13px 22px; border-top: 1px solid var(--border); }
.check-item:first-child { border-top: 0; }
.check-item .icon { margin-top: 2px; }
.check-item.success .icon { color: var(--success); }
.check-item.warning .icon { color: var(--warning); }
.check-item.danger .icon { color: var(--danger); }
.check-item strong { display: block; font-weight: 600; }
.check-item span { color: var(--text-3); font-size: 12.5px; }
/* ---------------------------------------------------------- VM cards */
.vm-list { display: grid; }
.vm-row { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 16px 22px; border-top: 1px solid var(--border); transition: background .15s; }
.vm-row:first-child { border-top: 0; }
.vm-row:hover { background: var(--surface-2); }
.vm-ident { display: flex; align-items: center; gap: 14px; min-width: 0; color: inherit; }
.vm-ident:hover { text-decoration: none; }
.vm-ident:hover .vm-name { color: var(--brand-text); }
.vm-avatar { position: relative; display: grid; place-items: center; width: 42px; height: 42px; flex: none; border-radius: 12px; background: var(--surface-3); color: var(--text-2); }
.vm-avatar.on { background: linear-gradient(135deg, var(--brand-soft), color-mix(in srgb, var(--info-soft) 70%, transparent)); color: var(--brand-text); }
.vm-avatar .status-dot { position: absolute; right: -2px; bottom: -2px; width: 12px; height: 12px; border: 2.5px solid var(--surface); border-radius: 50%; background: var(--neutral); }
.vm-avatar .status-dot.success { background: var(--success); }
.vm-avatar .status-dot.warning { background: var(--warning); }
.vm-avatar .status-dot.danger { background: var(--danger); }
.vm-name { font-size: 14.5px; font-weight: 650; transition: color .15s; }
.vm-sub { color: var(--text-3); font-size: 12.5px; }
.vm-cell-label { color: var(--text-3); font-size: 12px; }
.vm-cell-value { font-weight: 500; }
.vm-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
/* ---------------------------------------------------------- menus & dialogs */
.menu { position: relative; }
.menu > summary { list-style: none; }
.menu > summary::-webkit-details-marker { display: none; }
.menu-list {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 50; min-width: 230px; padding: 6px;
border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-lg);
animation: pop .12s ease-out;
}
button:hover, .icon-action:hover { transform: translateY(-1px); border-color: rgba(56,189,248,.38); background: rgba(56,189,248,.12); }
button.danger, .icon-action.danger { color: #fecaca; border-color: rgba(239,68,68,.28); background: rgba(239,68,68,.10); }
button.danger:hover, .icon-action.danger:hover { border-color: rgba(239,68,68,.55); background: rgba(239,68,68,.18); }
.icon-action.success { color: #bbf7d0; border-color: rgba(34,197,94,.3); background: rgba(34,197,94,.1); }
button.ghost { color: var(--muted); background: transparent; }
button.wide { width: 100%; min-height: 44px; }
button:disabled { cursor: not-allowed; opacity: .45; transform: none !important; }
.icon-action em { font-style: normal; }
.menu-list form { margin: 0; }
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border: 0; border-radius: 8px; background: none; color: var(--text); font: 500 13.5px/1.2 var(--font); text-align: left; cursor: pointer; }
.menu-item:hover { background: var(--surface-3); text-decoration: none; }
.menu-item .icon { color: var(--text-3); }
.menu-item.danger { color: var(--danger); }
.menu-item.danger .icon { color: var(--danger); }
.menu-item.danger:hover { background: var(--danger-soft); }
.menu-item small { display: block; color: var(--text-3); font-size: 12px; font-weight: 400; margin-top: 2px; }
.menu-sep { height: 1px; margin: 6px 4px; background: var(--border); }
@keyframes pop { from { opacity: 0; transform: translateY(-4px) scale(.98); } }
.form-grid, .auth-form { display: grid; gap: 14px; margin-top: 14px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-grid label, .auth-form label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; font-weight: 850; }
input, select {
width: 100%;
min-height: 42px;
padding: 10px 12px;
color: var(--text);
background: rgba(2,6,23,.56);
border: 1px solid var(--line);
border-radius: 12px;
outline: none;
dialog.dialog { width: min(440px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border); border-radius: var(--radius-xl); background: var(--surface); color: var(--text); box-shadow: var(--shadow-lg); }
dialog.dialog::backdrop { background: rgba(10, 13, 20, .45); backdrop-filter: blur(3px); }
dialog.dialog[open] { animation: pop .16s ease-out; }
.dialog-body { display: flex; gap: 16px; padding: 24px; }
.dialog-icon { display: grid; place-items: center; width: 44px; height: 44px; flex: none; border-radius: 12px; background: var(--danger-soft); color: var(--danger); }
.dialog-icon.brand { background: var(--brand-soft); color: var(--brand-text); }
.dialog-body h3 { font-size: 16px; }
.dialog-body p { margin-top: 6px; color: var(--text-2); }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 24px; border-top: 1px solid var(--border); background: var(--surface-2); border-radius: 0 0 var(--radius-xl) var(--radius-xl); }
/* ---------------------------------------------------------- feedback */
.alert { display: flex; gap: 12px; padding: 13px 16px; border: 1px solid transparent; border-radius: var(--radius); font-size: 13.5px; }
.alert .icon { margin-top: 1px; }
.alert strong { display: block; font-weight: 600; }
.alert.success { background: var(--success-soft); color: color-mix(in srgb, var(--success) 70%, var(--text)); border-color: color-mix(in srgb, var(--success) 22%, transparent); }
.alert.danger { background: var(--danger-soft); color: color-mix(in srgb, var(--danger) 72%, var(--text)); border-color: color-mix(in srgb, var(--danger) 22%, transparent); }
.alert.warning { background: var(--warning-soft); color: color-mix(in srgb, var(--warning) 62%, var(--text)); border-color: color-mix(in srgb, var(--warning) 25%, transparent); }
.alert.info { background: var(--info-soft); color: color-mix(in srgb, var(--info) 65%, var(--text)); border-color: color-mix(in srgb, var(--info) 22%, transparent); }
.alert.brand { background: var(--brand-soft); color: var(--brand-text); }
.alert .alert-action { margin-left: auto; align-self: center; }
.alerts { display: grid; gap: 10px; margin-bottom: 22px; }
.alerts:not(:has(> *)) { display: none; }
.empty { display: grid; justify-items: center; gap: 10px; padding: 52px 24px; text-align: center; }
.empty-art { display: grid; place-items: center; width: 64px; height: 64px; margin-bottom: 6px; border-radius: 18px; background: linear-gradient(135deg, var(--brand-soft), var(--info-soft)); color: var(--brand-text); }
.empty h3 { font-size: 16px; }
.empty p { max-width: 420px; color: var(--text-2); }
.empty .row { justify-content: center; margin-top: 6px; }
.progress { height: 8px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
.progress > span { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, var(--brand), #2f73f0); transition: width .4s ease; }
.progress.success > span { background: var(--success); }
.progress.danger > span { background: var(--danger); }
.progress.indeterminate > span { width: 35%; animation: indeterminate 1.3s ease-in-out infinite; }
@keyframes indeterminate { from { transform: translateX(-100%); } to { transform: translateX(300%); } }
.spinner { display: inline-block; width: 18px; height: 18px; border: 2.5px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
#toasts { position: fixed; right: 20px; bottom: 20px; z-index: 1000; display: grid; gap: 10px; width: min(380px, calc(100vw - 40px)); }
.toast { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); box-shadow: var(--shadow-lg); font-weight: 500; opacity: 0; transform: translateY(8px); transition: .22s ease; }
.toast.show { opacity: 1; transform: none; }
.toast .icon { margin-top: 1px; }
.toast.success .icon { color: var(--success); }
.toast.warning .icon { color: var(--warning); }
.toast.danger .icon { color: var(--danger); }
.toast.info .icon { color: var(--brand); }
/* ---------------------------------------------------------- upload */
.dropzone {
position: relative; display: grid; justify-items: center; gap: 8px; padding: 34px 24px;
border: 1.5px dashed var(--border-strong); border-radius: var(--radius-lg); background: var(--surface-2);
text-align: center; cursor: pointer; transition: border-color .15s, background .15s;
}
input:focus, select:focus { border-color: rgba(56,189,248,.6); box-shadow: 0 0 0 4px rgba(56,189,248,.11); }
input[type="file"] { padding: 12px; }
.form-subcard, .upload-box { padding: 14px; border: 1px dashed rgba(56,189,248,.32); border-radius: 16px; background: rgba(56,189,248,.055); }
.check-row { display: flex !important; align-items: flex-start; gap: 10px !important; color: var(--text) !important; }
.check-row input { width: auto; min-height: auto; margin-top: 3px; }
.dropzone:hover, .dropzone.dragover { border-color: var(--brand); background: color-mix(in srgb, var(--brand-soft) 50%, var(--surface)); }
.dropzone input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.dropzone-icon { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow-sm); }
.dropzone strong { font-size: 14.5px; }
.dropzone span { color: var(--text-3); font-size: 12.5px; }
.upload-file { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
.upload-file .grow { flex: 1; min-width: 0; }
.upload-stats { display: flex; justify-content: space-between; gap: 10px; margin-top: 8px; color: var(--text-3); font-size: 12.5px; }
.alert, .notice { padding: 12px 14px; border-radius: 14px; margin: 0 0 14px; font-weight: 850; line-height: 1.45; }
.notice { color: #bae6fd; background: rgba(56,189,248,.09); border: 1px solid rgba(56,189,248,.24); }
.alert.danger { color: #fecaca; background: rgba(239,68,68,.10); border: 1px solid rgba(239,68,68,.28); }
.alert.success { color: #bbf7d0; background: rgba(34,197,94,.10); border: 1px solid rgba(34,197,94,.26); }
/* ---------------------------------------------------------- steps / wizard */
.step-card { position: relative; }
.step-head { display: flex; gap: 14px; align-items: flex-start; padding: 20px 22px 0; }
.step-num { display: grid; place-items: center; width: 28px; height: 28px; flex: none; border-radius: 50%; background: var(--brand); color: #fff; font-weight: 700; font-size: 13px; box-shadow: 0 0 0 5px var(--brand-soft); }
.step-head h2 { font-size: 16px; }
.step-head p { margin-top: 2px; color: var(--text-3); font-size: 13px; }
.step-body { padding: 18px 22px 22px 64px; }
.summary { position: sticky; top: 24px; }
.summary-row { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-top: 1px solid var(--border); font-size: 13.5px; }
.summary-row:first-child { border-top: 0; }
.summary-row span { color: var(--text-3); }
.summary-row strong { font-weight: 600; text-align: right; overflow-wrap: anywhere; }
.services, .quick-actions, .knowledge-list { display: grid; gap: 10px; }
.service-row, .big-action, .knowledge-list div, .meta-grid div {
padding: 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(2,6,23,.35);
details.advanced { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }
details.advanced > summary { display: flex; align-items: center; gap: 10px; padding: 13px 16px; color: var(--text-2); font-weight: 600; cursor: pointer; list-style: none; }
details.advanced > summary::-webkit-details-marker { display: none; }
details.advanced > summary .chev { margin-left: auto; transition: transform .2s; }
details.advanced[open] > summary .chev { transform: rotate(180deg); }
details.advanced > .advanced-body { padding: 4px 16px 16px; }
/* ---------------------------------------------------------- terminal / code */
.terminal { margin: 0; padding: 16px 18px; max-height: 520px; overflow: auto; border-radius: var(--radius); background: var(--terminal-bg); color: var(--terminal-text); font: 12.5px/1.6 var(--mono); white-space: pre-wrap; word-break: break-word; }
.terminal.tall { max-height: 68vh; min-height: 360px; }
.terminal-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: var(--radius) var(--radius) 0 0; background: color-mix(in srgb, var(--terminal-bg) 88%, #fff); color: #8b95a7; font-size: 12px; font-weight: 600; }
.terminal-head + .terminal { border-radius: 0 0 var(--radius) var(--radius); }
.terminal-head i { width: 10px; height: 10px; border-radius: 50%; background: #3a4150; }
.code-line { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); font: 12.5px/1.4 var(--mono); overflow-x: auto; white-space: nowrap; }
/* ---------------------------------------------------------- login */
.auth { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); min-height: 100vh; }
.auth-hero {
position: relative; display: flex; flex-direction: column; justify-content: space-between; padding: 44px 52px; overflow: hidden;
background: radial-gradient(1100px 600px at -10% -10%, rgba(123, 107, 255, .55), transparent 60%), radial-gradient(800px 500px at 110% 110%, rgba(47, 115, 240, .45), transparent 55%), #0b0a1f;
color: #fff;
}
.service-link, .big-action { color: var(--text); text-decoration: none; }
.service-link:hover, .big-action:hover { border-color: rgba(56,189,248,.35); background: rgba(56,189,248,.08); }
.service-row small, .big-action span, .knowledge-list span, .meta-grid span { color: var(--muted); font-size: 12px; line-height: 1.45; }
.meta-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.meta-grid strong { overflow-wrap: anywhere; }
.auth-hero::after { content: ""; position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px); background-size: 44px 44px; mask-image: radial-gradient(ellipse at 30% 40%, #000 20%, transparent 75%); pointer-events: none; }
.auth-hero > * { position: relative; z-index: 1; }
.auth-hero .logo { padding: 0; color: #fff; }
.auth-hero .brand-name small { color: rgba(255,255,255,.6); }
.auth-hero h1 { max-width: 520px; color: #fff; font-size: 40px; line-height: 1.12; letter-spacing: -.035em; }
.auth-hero h1 span { background: linear-gradient(90deg, #c4bcff, #8fb8ff); -webkit-background-clip: text; background-clip: text; color: transparent; }
.auth-hero p { max-width: 460px; margin-top: 16px; color: rgba(255,255,255,.72); font-size: 15.5px; line-height: 1.6; }
.auth-features { display: grid; gap: 14px; margin-top: 34px; }
.auth-feature { display: flex; align-items: center; gap: 12px; color: rgba(255,255,255,.86); font-weight: 500; }
.auth-feature .icon-wrap { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.12); }
.auth-foot { color: rgba(255,255,255,.5); font-size: 12.5px; }
.auth-panel { display: grid; place-items: center; padding: 40px 24px; background: var(--surface); }
.auth-form { width: min(380px, 100%); }
.auth-form h2 { font-size: 24px; font-weight: 700; letter-spacing: -.025em; }
.auth-form > p { margin-top: 6px; color: var(--text-2); }
.auth-mobile-brand { display: none; }
pre { overflow: auto; margin: 12px 0 0; padding: 14px; border-radius: 14px; background: rgba(2,6,23,.82); border: 1px solid rgba(148,163,184,.18); color: #dbeafe; line-height: 1.58; font-size: 13px; }
.progress { height: 10px; overflow: hidden; border-radius: 999px; background: rgba(15,23,42,.9); border: 1px solid var(--line); }
.progress.big { height: 16px; }
.progress span { display: block; height: 100%; width: 0; min-width: 4px; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--green)); box-shadow: 0 0 24px rgba(56,189,248,.32); transition: width .25s ease; }
.upload-progress-wrap { display: grid; gap: 10px; padding: 14px; border-radius: 16px; border: 1px solid rgba(56,189,248,.28); background: rgba(56,189,248,.08); }
.upload-progress-wrap[hidden] { display: none; }
.upload-progress-head, .upload-progress-meta { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.upload-progress-head strong, .upload-progress-head span { font-weight: 950; }
.upload-progress-head span { color: var(--accent); }
.upload-progress-meta { color: var(--muted); font-size: 12px; }
body.upload-active .v-nav a, body.upload-active .top-meta a, body.upload-active table a { opacity: .55; }
/* ---------------------------------------------------------- console */
.console-page { display: flex; flex-direction: column; height: 100vh; background: #05070b; color: #e6e9ef; }
.console-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; background: #0d1017; border-bottom: 1px solid #1d222d; }
.console-bar .btn { background: #161b24; border-color: #262d3a; color: #e6e9ef; }
.console-bar .btn:hover { background: #1f2531; }
.console-frame { flex: 1; width: 100%; border: 0; background: #000; }
.operation-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
.op-log { min-height: 420px; max-height: 650px; white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.command-box { white-space: pre-wrap; overflow-wrap: anywhere; }
.live-dot::before { content: ""; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 18px var(--green); }
/* ---------------------------------------------------------- misc */
.hero-card { position: relative; overflow: hidden; padding: 26px 28px; border: 0; background: radial-gradient(600px 240px at 100% 0%, rgba(47,115,240,.35), transparent 60%), radial-gradient(500px 260px at 0% 100%, rgba(123,107,255,.45), transparent 60%), #14112e; color: #fff; }
.hero-card h2 { color: #fff; font-size: 20px; }
.hero-card p { margin-top: 6px; color: rgba(255,255,255,.75); max-width: 560px; }
.hero-card .btn { border-color: transparent; }
.hero-card .btn-glass { background: rgba(255,255,255,.12); color: #fff; border: 1px solid rgba(255,255,255,.18); box-shadow: none; }
.hero-card .btn-glass:hover { background: rgba(255,255,255,.2); }
.help-card h3 { margin-bottom: 6px; }
.help-card p { color: var(--text-2); }
.help-card ol, .help-card ul { margin: 10px 0 0; padding-left: 20px; color: var(--text-2); }
.help-card li { margin: 5px 0; }
.release { padding: 18px 22px; border-top: 1px solid var(--border); }
.release:first-child { border-top: 0; }
.release ul { margin: 10px 0 0; padding-left: 18px; color: var(--text-2); }
.release li { margin: 4px 0; }
.auth-body { display: grid; place-items: center; padding: 22px; }
.auth-shell { width: min(460px, 100%); }
.auth-card { padding: 28px; }
.auth-brand { margin-bottom: 8px; }
.mobile-menu-toggle { display: none; position: fixed; right: 14px; bottom: 14px; z-index: 60; min-height: 44px; border-color: rgba(56,189,248,.38); background: linear-gradient(135deg, rgba(56,189,248,.95), rgba(96,165,250,.95)); color: #00111f; box-shadow: var(--shadow); }
#v-toast-wrap { position: fixed; right: 16px; bottom: 16px; z-index: 100; display: grid; gap: 10px; }
.v-toast { opacity: 0; transform: translateY(8px); padding: 12px 14px; border-radius: 14px; background: rgba(15,23,42,.95); border: 1px solid var(--line); box-shadow: var(--shadow-soft); transition: .22s ease; }
.v-toast.show { opacity: 1; transform: translateY(0); }
.v-toast.ok { border-color: rgba(34,197,94,.32); }
.v-toast.warn { border-color: rgba(245,158,11,.34); }
.console-body { height: 100vh; overflow: hidden; background: #020617; }
.console-window { display: grid; grid-template-rows: 54px 1fr; height: 100vh; }
.console-topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; background: rgba(15,23,42,.96); border-bottom: 1px solid var(--line); }
.console-title { display: flex; align-items: baseline; gap: 10px; color: var(--text); }
.console-title span { color: var(--muted); font-size: 12px; }
.console-main, .console-frame-full { width: 100%; height: 100%; border: 0; background: #000; }
.console-error-wrap { display: grid; place-items: center; padding: 18px; min-height: 100vh; }
.console-error-card { width: min(760px, calc(100vw - 32px)); }
@media (max-width: 1220px) { .three { grid-template-columns: 1fr; } .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 980px) {
.v-layout { grid-template-columns: 1fr; }
.v-sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: min(290px, calc(100vw - 34px)); transform: translateX(-105%); transition: transform .22s ease; }
.v-sidebar.open { transform: translateX(0); }
.mobile-menu-toggle { display: inline-flex; }
.v-main { padding: 12px; }
.v-topbar, .topbar { align-items: flex-start; flex-direction: column; }
.top-meta { justify-content: flex-start; }
.stats, .compact-stats, .two, .form-row, .operation-summary, .meta-grid { grid-template-columns: 1fr; }
.compact-actions, .vm-table th:last-child, .vm-table td:last-child { justify-content: flex-start; text-align: left; }
table { display: block; overflow-x: auto; border-spacing: 0 8px; }
.icon-action em { display: none; }
/* ---------------------------------------------------------- responsive */
@media (max-width: 1180px) {
.grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-main { grid-template-columns: 1fr; }
.summary { position: static; }
.vm-row { grid-template-columns: minmax(0, 1fr) auto; }
.vm-row .vm-col { display: none; }
}
.resource-mini-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin-bottom: 10px; }
.resource-mini-grid div { display: grid; gap: 3px; padding: 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); }
.resource-mini-grid span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.resource-mini-grid b { font-size: 14px; overflow-wrap: anywhere; }
.boot-order-list { display: grid; gap: 8px; }
.boot-order-item { display: flex; align-items: center; gap: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); cursor: grab; }
.boot-order-item:active { cursor: grabbing; }
.boot-order-item.dragging { opacity: .55; border-color: var(--accent); }
.boot-order-item small { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
.drag-handle { color: var(--muted); font-weight: 900; }
@media (max-width: 1180px) { .three { grid-template-columns: 1fr; } .resource-mini-grid { grid-template-columns: 1fr; } }
/* Virtuality live status + toast layer */
#v-toast-wrap { position: fixed; top: 14px; right: 14px; z-index: 10000; display: grid; gap: 8px; width: min(360px, calc(100vw - 28px)); pointer-events: none; }
.v-toast { transform: translateY(-8px); opacity: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: #fff; color: var(--text); box-shadow: 0 12px 32px rgba(15,23,42,.18); font-size: 13px; font-weight: 800; transition: .22s ease; }
.v-toast.show { transform: translateY(0); opacity: 1; }
.v-toast.ok { border-color: rgba(22,163,74,.28); box-shadow: 0 12px 32px rgba(22,163,74,.16); }
.v-toast.warn { border-color: rgba(217,119,6,.32); box-shadow: 0 12px 32px rgba(217,119,6,.16); }
.v-toast.err { border-color: rgba(220,38,38,.32); box-shadow: 0 12px 32px rgba(220,38,38,.16); }
.live-badge { margin-left: 10px; vertical-align: middle; }
@media (max-width: 1024px) {
.app { grid-template-columns: 1fr; background: none; }
.sidebar { position: fixed; left: 0; top: 0; z-index: 200; width: 280px; transform: translateX(-100%); transition: transform .25s ease; box-shadow: var(--shadow-lg); }
body.nav-open .sidebar { transform: none; }
body.nav-open::after { content: ""; position: fixed; inset: 0; z-index: 150; background: rgba(10,13,20,.4); }
.mobilebar { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(10px); }
.mobilebar .logo { padding: 0; }
.content { padding: 22px 16px 48px; }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.span-2 { grid-column: auto; }
.auth { grid-template-columns: 1fr; }
.auth-hero { display: none; }
.auth-mobile-brand { display: flex; margin-bottom: 28px; }
}
@media (max-width: 640px) {
h1 { font-size: 22px; }
.grid-4 { grid-template-columns: 1fr 1fr; gap: 12px; }
.form-row, .form-row-3 { grid-template-columns: 1fr; }
.stat { padding: 14px; }
.stat-value { font-size: 21px; }
.card-header, .card-body, .list-item, .vm-row, .table td, .table th { padding-left: 16px; padding-right: 16px; }
.step-body { padding: 16px; }
.page-header { align-items: flex-start; }
.page-actions { width: 100%; }
.vm-actions .btn-label { display: none; }
.hide-sm { display: none !important; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
+93
Просмотреть файл
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Двоичный файл не отображается.
Двоичные данные
Двоичный файл не отображается.
+60
Просмотреть файл
@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<!-- Lucide icons (ISC License, https://lucide.dev) -->
<symbol id="i-layout-dashboard" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1" /><rect width="7" height="5" x="14" y="3" rx="1" /><rect width="7" height="9" x="14" y="12" rx="1" /><rect width="7" height="5" x="3" y="16" rx="1" /></symbol>
<symbol id="i-server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /><rect width="20" height="8" x="2" y="14" rx="2" ry="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14" /><path d="M12 5v14" /></symbol>
<symbol id="i-disc-3" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M6 12c0-1.7.7-3.2 1.8-4.2" /><circle cx="12" cy="12" r="2" /><path d="M18 12c0 1.7-.7 3.2-1.8 4.2" /></symbol>
<symbol id="i-hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01" /><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /><path d="M21.946 12.013H2.054" /><path d="M6 16h.01" /></symbol>
<symbol id="i-network" viewBox="0 0 24 24"><rect x="16" y="16" width="6" height="6" rx="1" /><rect x="2" y="16" width="6" height="6" rx="1" /><rect x="9" y="2" width="6" height="6" rx="1" /><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" /><path d="M12 12V8" /></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" /></symbol>
<symbol id="i-list-checks" viewBox="0 0 24 24"><path d="M13 5h8" /><path d="M13 12h8" /><path d="M13 19h8" /><path d="m3 17 2 2 4-4" /><path d="m3 7 2 2 4-4" /></symbol>
<symbol id="i-scroll-text" viewBox="0 0 24 24"><path d="M15 12h-5" /><path d="M15 8h-5" /><path d="M19 17V5a2 2 0 0 0-2-2H4" /><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" /></symbol>
<symbol id="i-refresh-cw" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M8 16H3v5" /></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24"><path d="M12 20v2" /><path d="M12 2v2" /><path d="M17 20v2" /><path d="M17 2v2" /><path d="M2 12h2" /><path d="M2 17h2" /><path d="M2 7h2" /><path d="M20 12h2" /><path d="M20 17h2" /><path d="M20 7h2" /><path d="M7 20v2" /><path d="M7 2v2" /><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
<symbol id="i-memory-stick" viewBox="0 0 24 24"><path d="M12 12v-2" /><path d="M12 18v-2" /><path d="M16 12v-2" /><path d="M16 18v-2" /><path d="M2 11h1.5" /><path d="M20 18v-2" /><path d="M20.5 11H22" /><path d="M4 18v-2" /><path d="M8 12v-2" /><path d="M8 18v-2" /><rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
<symbol id="i-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5V19A9 3 0 0 0 21 19V5" /><path d="M3 12A9 3 0 0 0 21 12" /></symbol>
<symbol id="i-circle-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path d="M12 17h.01" /></symbol>
<symbol id="i-log-out" viewBox="0 0 24 24"><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" /></symbol>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol>
<symbol id="i-monitor" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2" /><line x1="8" x2="16" y1="21" y2="21" /><line x1="12" x2="12" y1="17" y2="21" /></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
<symbol id="i-square" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
<symbol id="i-power" viewBox="0 0 24 24"><path d="M12 2v10" /><path d="M18.4 6.6a9 9 0 1 1-12.77.04" /></symbol>
<symbol id="i-rotate-cw" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /></symbol>
<symbol id="i-trash-2" viewBox="0 0 24 24"><path d="M10 11v6" /><path d="M14 11v6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
<symbol id="i-ellipsis" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" /></symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" /></symbol>
<symbol id="i-cloud-upload" viewBox="0 0 24 24"><path d="M12 13v8" /><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" /><path d="m8 17 4-4 4 4" /></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></symbol>
<symbol id="i-circle-check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m16 9-5.5 5.5L8 12" /></symbol>
<symbol id="i-triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /><path d="M12 9v4" /><path d="M12 17h.01" /></symbol>
<symbol id="i-circle-x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m15 9-6 6" /><path d="m9 9 6 6" /></symbol>
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" /></symbol>
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></symbol>
<symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14" /><path d="m12 5 7 7-7 7" /></symbol>
<symbol id="i-external-link" viewBox="0 0 24 24"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></symbol>
<symbol id="i-zap" viewBox="0 0 24 24"><path d="M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z" /></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34" /><circle cx="11" cy="11" r="8" /></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 5h16" /><path d="M4 12h16" /><path d="M4 19h16" /></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3" /><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></symbol>
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" /></symbol>
<symbol id="i-package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" /><path d="M12 22V12" /><polyline points="3.29 7 12 12 20.71 7" /><path d="m7.5 4.27 9 5.15" /></symbol>
<symbol id="i-terminal" viewBox="0 0 24 24"><path d="M12 19h8" /><path d="m4 17 6-6-6-6" /></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></symbol>
<symbol id="i-gauge" viewBox="0 0 24 24"><path d="m12 14 4-4" /><path d="M3.34 19a10 10 0 1 1 17.32 0" /></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" /></symbol>
<symbol id="i-sparkles" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" /><path d="M20 2v4" /><path d="M22 4h-4" /><circle cx="4" cy="20" r="2" /></symbol>
<symbol id="i-layers" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" /><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" /><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" /></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><rect x="14" y="3" width="5" height="18" rx="1" /><rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
<symbol id="i-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
<symbol id="i-circle-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1" /><circle cx="12" cy="12" r="10" /></symbol>
<symbol id="i-plug" viewBox="0 0 24 24"><path d="M12 22v-5" /><path d="M15 8V2" /><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z" /><path d="M9 8V2" /></symbol>
</svg>

После

Ширина:  |  Высота:  |  Размер: 10 KiB

+460 -302
Просмотреть файл
@@ -1,319 +1,477 @@
/* Virtuality panel UI behaviour. No dependencies. */
(() => {
const app = {
refreshTimers: [],
'use strict';
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const ICONS = '/static/icons.svg';
const icon = (name, cls = 'icon') => `<svg class="${cls}" aria-hidden="true"><use href="${ICONS}#i-${name}"></use></svg>`;
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
const VM_STATES = {
'running': ['Работает', 'success'], 'idle': ['Работает', 'success'], 'blocked': ['Работает', 'success'],
'paused': ['Приостановлена', 'warning'], 'pmsuspended': ['Спящий режим', 'warning'],
'in shutdown': ['Выключается', 'warning'], 'shut off': ['Выключена', 'neutral'], 'shutoff': ['Выключена', 'neutral'],
'crashed': ['Сбой', 'danger'], 'dying': ['Останавливается', 'warning'],
};
const OP_STATES = { success: ['Готово', 'success'], error: ['Ошибка', 'danger'], running: ['Выполняется', 'info'], queued: ['В очереди', 'neutral'] };
const vmState = (state) => VM_STATES[String(state || '').toLowerCase()] || [state || 'Неизвестно', 'neutral'];
const opState = (status) => OP_STATES[String(status || '').toLowerCase()] || [status || '—', 'neutral'];
const badge = ([label, tone]) => `<span class="dot"></span>${escapeHtml(label)}`;
function qs(selector, root = document) { return root.querySelector(selector); }
function qsa(selector, root = document) { return Array.from(root.querySelectorAll(selector)); }
function ensureThemeStyles() {
if (qs('link[data-virtuality-themes="1"]')) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/static/themes.css';
link.dataset.virtualityThemes = '1';
document.head.appendChild(link);
function bytes(value) {
if (!value) return '0 Б';
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'];
let n = value; let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i += 1; }
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
function getTheme() {
return localStorage.getItem('virtualityTheme') || 'neon';
}
function applyTheme(theme) {
const selected = theme === 'macos' ? 'macos' : 'neon';
document.documentElement.dataset.theme = selected;
localStorage.setItem('virtualityTheme', selected);
qsa('[data-theme-select]').forEach((btn) => {
btn.classList.toggle('is-active', btn.dataset.themeSelect === selected);
/* ---------------------------------------------------- theme */
function applyTheme(mode) {
const dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
$$('[data-theme-toggle]').forEach((btn) => {
btn.innerHTML = icon(dark ? 'sun' : 'moon');
btn.title = dark ? 'Светлая тема' : 'Тёмная тема';
});
}
function wireThemeSwitcher() {
ensureThemeStyles();
const nav = qs('.v-nav');
if (!nav || qs('.theme-switcher')) {
applyTheme(getTheme());
return;
}
const switcher = document.createElement('div');
switcher.className = 'theme-switcher';
switcher.innerHTML = '<div class="theme-switcher-label">Тема</div><div class="theme-buttons"><button type="button" class="theme-button" data-theme-select="neon">Neon</button><button type="button" class="theme-button" data-theme-select="macos">MacOS</button></div>';
nav.appendChild(switcher);
qsa('[data-theme-select]', switcher).forEach((btn) => {
btn.addEventListener('click', () => applyTheme(btn.dataset.themeSelect));
});
applyTheme(getTheme());
function currentMode() { try { return localStorage.getItem('virtualityTheme') || 'system'; } catch (_) { return 'system'; } }
function wireTheme() {
applyTheme(currentMode());
$$('[data-theme-toggle]').forEach((btn) => btn.addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
try { localStorage.setItem('virtualityTheme', next); } catch (_) {}
applyTheme(next);
}));
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (currentMode() === 'system') applyTheme('system'); });
}
function activeSidebar(pathname = window.location.pathname) {
qsa('.v-nav a').forEach((item) => {
const url = new URL(item.href, window.location.href);
const path = url.pathname;
const active = path === '/' ? pathname === '/' : pathname.startsWith(path);
item.classList.toggle('active', active);
});
qsa('.v-nav-group').forEach((group) => {
const hasActive = qsa('a.active', group).length > 0;
group.classList.toggle('open', hasActive);
});
}
async function refreshFragments() {
const fragments = qsa('[data-refresh-url]');
for (const fragment of fragments) {
const url = fragment.dataset.refreshUrl;
if (!url) continue;
try {
const response = await fetch(url, { cache: 'no-store', headers: { 'X-Requested-With': 'XMLHttpRequest' }});
const payload = await response.json();
if (!payload.ok) continue;
if (payload.html) fragment.innerHTML = payload.html;
if (payload.text) fragment.textContent = payload.text;
} catch (_) {}
}
}
function startAutoRefresh(root = document) {
const interval = Number(qs('[data-auto-refresh]', root)?.dataset.autoRefresh || 0);
if (interval > 0) {
app.refreshTimers.forEach((timer) => clearInterval(timer));
app.refreshTimers = [];
const timer = setInterval(refreshFragments, interval * 1000);
app.refreshTimers.push(timer);
refreshFragments();
}
}
function wireNavGroups(root = document) {
qsa('.v-nav-group-title', root).forEach((title) => {
if (title.dataset.groupWired === '1') return;
title.dataset.groupWired = '1';
title.addEventListener('click', () => title.closest('.v-nav-group')?.classList.toggle('open'));
});
}
function wireMobileMenu() {
const sidebar = qs('.v-sidebar');
if (!sidebar || qs('.mobile-menu-toggle')) return;
const btn = document.createElement('button');
btn.className = 'mobile-menu-toggle';
btn.type = 'button';
btn.textContent = '☰ Меню';
btn.addEventListener('click', () => sidebar.classList.toggle('open'));
document.body.appendChild(btn);
}
function initPanel() {
wireThemeSwitcher();
wireNavGroups();
wireMobileMenu();
activeSidebar();
startAutoRefresh();
}
document.addEventListener('DOMContentLoaded', initPanel);
applyTheme(getTheme());
})();
/* VM visual cards from raw virsh output */
(() => {
function qs(selector, root = document) { return root.querySelector(selector); }
function qsa(selector, root = document) { return Array.from(root.querySelectorAll(selector)); }
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[ch]));
}
function rawPreBySummary(summaryText) {
const details = qsa('.raw-details').find((item) => (qs('summary', item)?.textContent || '').includes(summaryText));
return qs('pre', details)?.textContent.trim() || '';
}
function parseKeyValues(raw) {
const data = {};
raw.split('\n').forEach((line) => {
const idx = line.indexOf(':');
if (idx < 0) return;
const key = line.slice(0, idx).trim().toLowerCase();
const value = line.slice(idx + 1).trim();
if (key) data[key] = value;
});
return data;
}
function sizeToGiB(value) {
const raw = String(value || '').trim();
const n = Number(raw.replace(',', '.').replace(/[^0-9.]/g, ''));
if (!Number.isFinite(n) || n <= 0) return 0;
if (/kib|kb/i.test(raw)) return n / 1024 / 1024;
if (/mib|mb/i.test(raw)) return n / 1024;
if (/gib|gb/i.test(raw)) return n;
return n / 1024 / 1024;
}
function renderInfo() {
const box = qs('#vm-info-visual');
if (!box) return;
const raw = rawPreBySummary('dominfo');
const data = parseKeyValues(raw);
const cpu = data['cpu(s)'] || data['cpu'] || '—';
const state = data.state || 'unknown';
const osType = data['os type'] || data['os'] || '—';
const maxMem = sizeToGiB(data['max memory']);
const usedMem = sizeToGiB(data['used memory']);
const memPct = maxMem > 0 ? Math.max(0, Math.min(100, Math.round((usedMem / maxMem) * 100))) : 0;
const cpuPct = Math.max(8, Math.min(100, Number(cpu) ? Number(cpu) * 12 : 18));
const autostartText = (qsa('.top-meta .status').find((item) => item.textContent.toLowerCase().includes('autostart'))?.textContent || 'autostart: —').replace(/^autostart:\s*/i, '');
box.innerHTML = `
<div class="metric-strip">
<div class="metric-tile"><span>Состояние</span><strong>${escapeHtml(state)}</strong></div>
<div class="metric-tile"><span>CPU</span><strong>${escapeHtml(cpu)}</strong></div>
<div class="metric-tile"><span>Тип ОС</span><strong>${escapeHtml(osType)}</strong></div>
</div>
<div class="chart-row">
<div class="donut" style="--value:${memPct};--donut-color:var(--accent)"><div class="donut-value">${memPct}%</div></div>
<div class="bar-list">
<div class="bar-item"><div class="bar-head"><span>Память</span><b>${usedMem ? usedMem.toFixed(1) : '—'} / ${maxMem ? maxMem.toFixed(1) : '—'} GB</b></div><div class="mini-bar"><span style="--value:${memPct}%"></span></div></div>
<div class="bar-item"><div class="bar-head"><span>vCPU capacity</span><b>${escapeHtml(cpu)} vCPU</b></div><div class="mini-bar"><span style="--value:${cpuPct}%"></span></div></div>
<div class="bar-item"><div class="bar-head"><span>Автозапуск</span><b>${escapeHtml(autostartText)}</b></div><div class="mini-bar"><span style="--value:${/enable|включ/i.test(autostartText) ? 100 : 18}%"></span></div></div>
</div>
</div>`;
}
function parseDisks(raw) {
const rows = [];
raw.split('\n').map((line) => line.trim()).filter(Boolean).forEach((line) => {
if (/^(target|type|[-\s]+$)/i.test(line)) return;
const parts = line.split(/\s+/);
if (parts.length >= 4 && /^(file|block|dir|network|volume)$/i.test(parts[0])) {
rows.push({ type: parts[0], device: parts[1], target: parts[2], source: parts.slice(3).join(' ') });
} else if (parts.length >= 2) {
rows.push({ type: 'file', device: 'disk', target: parts[0], source: parts.slice(1).join(' ') });
}
});
return rows;
}
function renderDisks() {
const box = qs('#vm-disk-visual');
if (!box) return;
const rows = parseDisks(rawPreBySummary('domblklist'));
if (!rows.length) {
box.innerHTML = '<div class="visual-empty">Диски не найдены в выводе libvirt.</div>';
return;
}
box.innerHTML = `<div class="visual-list">${rows.map((row) => `
<div class="visual-item">
<div class="visual-icon">▣</div>
<div><b>${escapeHtml(row.target)}</b><small>${escapeHtml(row.device)} · ${escapeHtml(row.type)}<br>${escapeHtml(row.source)}</small></div>
<span class="status ok">disk</span>
</div>`).join('')}</div>`;
}
function parseInterfaces(raw) {
const rows = [];
raw.split('\n').map((line) => line.trim()).filter(Boolean).forEach((line) => {
if (/^(interface|[-\s]+$)/i.test(line)) return;
const parts = line.split(/\s+/);
if (parts.length >= 5) rows.push({ iface: parts[0], type: parts[1], source: parts[2], model: parts[3], mac: parts[4] });
else if (parts.length >= 2) rows.push({ iface: parts[0], type: parts[1] || '—', source: parts[2] || '—', model: parts[3] || '—', mac: parts[4] || '—' });
});
return rows;
}
function renderNetwork() {
const box = qs('#vm-network-visual');
if (!box) return;
const rows = parseInterfaces(rawPreBySummary('domiflist'));
if (!rows.length) {
box.innerHTML = '<div class="visual-empty">Сетевые интерфейсы не найдены в выводе libvirt.</div>';
return;
}
box.innerHTML = `<div class="visual-list">${rows.map((row) => `
<div class="visual-item">
<div class="visual-icon">⇄</div>
<div><b>${escapeHtml(row.iface)}</b><small>${escapeHtml(row.type)} · ${escapeHtml(row.source)} · ${escapeHtml(row.model)}<br>${escapeHtml(row.mac)}</small></div>
<span class="status ok">link</span>
</div>`).join('')}</div>`;
}
function renderVmVisuals() {
if (!qs('#vm-info-visual') && !qs('#vm-disk-visual') && !qs('#vm-network-visual')) return;
renderInfo();
renderDisks();
renderNetwork();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', renderVmVisuals); else renderVmVisuals();
})();
/* Virtuality live status + toast layer */
(() => {
if (window.VirtualityLiveStatus) return;
window.VirtualityLiveStatus = true;
function qs(s, r = document) { return r.querySelector(s); }
function qsa(s, r = document) { return Array.from(r.querySelectorAll(s)); }
function toast(message, type = 'ok') {
/* ---------------------------------------------------- toasts */
function toast(message, tone = 'success') {
if (!message) return;
let wrap = qs('#v-toast-wrap');
if (!wrap) { wrap = document.createElement('div'); wrap.id = 'v-toast-wrap'; document.body.appendChild(wrap); }
let wrap = $('#toasts');
if (!wrap) { wrap = document.createElement('div'); wrap.id = 'toasts'; document.body.appendChild(wrap); }
const icons = { success: 'circle-check', warning: 'triangle-alert', danger: 'circle-x', info: 'info' };
const item = document.createElement('div');
item.className = 'v-toast ' + type;
item.textContent = message;
item.className = `toast ${tone}`;
item.setAttribute('role', 'status');
item.innerHTML = `${icon(icons[tone] || 'info')}<div>${escapeHtml(message)}</div>`;
wrap.appendChild(item);
setTimeout(() => item.classList.add('show'), 20);
setTimeout(() => { item.classList.remove('show'); setTimeout(() => item.remove(), 260); }, 3600);
requestAnimationFrame(() => item.classList.add('show'));
setTimeout(() => { item.classList.remove('show'); setTimeout(() => item.remove(), 250); }, 4200);
}
function vmNameFromRow(row) {
const link = qs('a[href^="/vm/"]', row);
if (!link) return '';
try { const p = new URL(link.href, location.href).pathname.split('/').filter(Boolean); return p[0] === 'vm' ? decodeURIComponent(p[1] || '') : ''; } catch (_) { return ''; }
}
function stateClass(state) { const v = String(state || '').toLowerCase(); return v.includes('running') ? 'ok' : v.includes('shut') ? 'err' : 'warn'; }
function updateDashboardRows(vms) {
if (!Array.isArray(vms)) return;
const map = new Map(vms.map((vm) => [vm.name, vm]));
qsa('table.vm-table tbody tr').forEach((row) => {
const name = vmNameFromRow(row);
if (!name || !map.has(name)) return;
const vm = map.get(name);
const cells = qsa('td', row);
if (cells[0]) cells[0].textContent = vm.id || '-';
if (cells[2] && vm.ip && vm.ip !== '—' && vm.ip !== 'not available') cells[2].textContent = vm.ip;
if (cells[3]) {
let badge = qs('.status', cells[3]);
if (!badge) { badge = document.createElement('span'); cells[3].textContent = ''; cells[3].appendChild(badge); }
badge.className = 'status ' + (vm.state_css || stateClass(vm.state));
badge.textContent = vm.state || 'unknown';
}
if (cells[4] && typeof vm.autostart_enabled !== 'undefined') {
const button = qs('button.status-button', cells[4]);
if (button) {
button.textContent = vm.autostart_label || (vm.autostart_enabled ? 'enabled' : 'disabled');
button.className = 'status status-button ' + (vm.autostart_enabled ? 'ok' : 'err');
}
}
});
}
function updateDetailHeader(vms) {
const title = qs('.v-title');
if (!title || !Array.isArray(vms)) return;
const vm = vms.find((item) => item.name === title.textContent.trim());
if (!vm) return;
let badge = qs('#vm-live-badge');
if (!badge) { badge = document.createElement('span'); badge.id = 'vm-live-badge'; title.insertAdjacentElement('afterend', badge); }
badge.className = 'status live-badge ' + (vm.state_css || stateClass(vm.state));
badge.textContent = vm.state || 'unknown';
}
async function refreshLiveStatus() {
if (document.hidden || !qs('.v-main')) return;
window.vToast = toast;
function flashToast() {
try {
const r = await fetch('/live/status', { cache: 'no-store', headers: { 'Accept': 'application/json' }});
if (!r.ok) return;
const p = await r.json();
if (!p.ok) return;
updateDashboardRows(p.vms);
updateDetailHeader(p.vms);
const raw = sessionStorage.getItem('virtualityToast');
if (!raw) return;
sessionStorage.removeItem('virtualityToast');
const data = JSON.parse(raw);
toast(data.message, data.tone);
} catch (_) {}
}
document.addEventListener('submit', (event) => {
const form = event.target;
if (!form || !form.action) return;
const parts = new URL(form.action, location.href).pathname.split('/').filter(Boolean);
if (parts[0] !== 'vm' || parts.length < 3) return;
const labels = { start: 'Команда запуска VM отправлена', shutdown: 'Команда мягкого выключения VM отправлена', reboot: 'Команда перезагрузки VM отправлена', destroy: 'Команда принудительного выключения VM отправлена', autostart: 'Автозапуск VM включается', 'autostart-disable': 'Автозапуск VM отключается', delete: 'Удаление VM запущено' };
const action = parts[2];
if (labels[action]) { const type = action === 'destroy' || action === 'delete' ? 'warn' : 'ok'; sessionStorage.setItem('virtualityToast', labels[action]); toast(labels[action], type); setTimeout(refreshLiveStatus, 900); setTimeout(refreshLiveStatus, 2500); }
}, true);
function boot() { const m = sessionStorage.getItem('virtualityToast'); if (m) { sessionStorage.removeItem('virtualityToast'); toast(m, 'ok'); } refreshLiveStatus(); setInterval(refreshLiveStatus, 5000); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
/* ---------------------------------------------------- navigation & menus */
function wireNav() {
$$('[data-nav-toggle]').forEach((btn) => btn.addEventListener('click', () => document.body.classList.toggle('nav-open')));
document.addEventListener('click', (event) => {
if (document.body.classList.contains('nav-open') && !event.target.closest('.sidebar, [data-nav-toggle]')) document.body.classList.remove('nav-open');
$$('details.menu[open]').forEach((menu) => { if (!menu.contains(event.target)) menu.removeAttribute('open'); });
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
document.body.classList.remove('nav-open');
$$('details.menu[open]').forEach((menu) => menu.removeAttribute('open'));
});
$$('details.menu').forEach((menu) => menu.addEventListener('toggle', () => {
if (menu.open) $$('details.menu[open]').forEach((other) => { if (other !== menu) other.removeAttribute('open'); });
}));
}
/* ---------------------------------------------------- forms: confirm, busy, toast */
function confirmDialog(form) {
return new Promise((resolve) => {
const dialog = $('#confirm-dialog');
if (!dialog || typeof dialog.showModal !== 'function') { resolve(window.confirm(form.dataset.confirm)); return; }
const danger = form.dataset.confirmTone !== 'brand';
$('[data-confirm-title]', dialog).textContent = form.dataset.confirmTitle || 'Подтвердите действие';
$('[data-confirm-text]', dialog).textContent = form.dataset.confirm;
const ok = $('[data-confirm-ok]', dialog);
ok.textContent = form.dataset.confirmButton || 'Продолжить';
ok.className = `btn ${danger ? 'btn-danger' : 'btn-primary'}`;
$('.dialog-icon', dialog).className = `dialog-icon ${danger ? '' : 'brand'}`;
const done = (value) => { dialog.close(); ok.removeEventListener('click', onOk); dialog.removeEventListener('close', onClose); resolve(value); };
const onOk = () => done(true);
const onClose = () => resolve(false);
ok.addEventListener('click', onOk);
dialog.addEventListener('close', onClose, { once: true });
dialog.showModal();
});
}
function markBusy(form, submitter) {
const button = submitter || $('button[type=submit], button:not([type])', form);
if (!button || form.dataset.noBusy !== undefined) return;
button.disabled = true;
if (!button.classList.contains('switch') && !button.classList.contains('menu-item')) {
button.dataset.label = button.innerHTML;
button.innerHTML = `<span class="spinner"></span>${escapeHtml(button.dataset.busy || button.textContent.trim())}`;
}
}
function wireForms() {
document.addEventListener('submit', async (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || form.dataset.uploader !== undefined) return;
if (form.dataset.confirm && form.dataset.confirmed !== '1') {
event.preventDefault();
const submitter = event.submitter;
if (await confirmDialog(form)) {
form.dataset.confirmed = '1';
if (form.dataset.toast) sessionStorage.setItem('virtualityToast', JSON.stringify({ message: form.dataset.toast, tone: form.dataset.toastTone || 'success' }));
markBusy(form, submitter);
form.submit();
}
return;
}
if (form.dataset.toast) sessionStorage.setItem('virtualityToast', JSON.stringify({ message: form.dataset.toast, tone: form.dataset.toastTone || 'success' }));
const submitter = event.submitter;
setTimeout(() => markBusy(form, submitter), 0);
});
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return;
$$('button[data-label]').forEach((btn) => { btn.innerHTML = btn.dataset.label; btn.disabled = false; });
});
}
/* ---------------------------------------------------- tabs */
function wireTabs() {
$$('[data-tabs]').forEach((tabs) => {
const buttons = $$('[data-tab]', tabs);
const select = (id, push) => {
buttons.forEach((btn) => btn.setAttribute('aria-selected', String(btn.dataset.tab === id)));
$$('[data-tab-panel]').forEach((panel) => { panel.hidden = panel.dataset.tabPanel !== id; });
if (push) history.replaceState(null, '', `#${id}`);
};
buttons.forEach((btn) => btn.addEventListener('click', () => select(btn.dataset.tab, true)));
const initial = location.hash.slice(1);
select(buttons.some((btn) => btn.dataset.tab === initial) ? initial : buttons[0]?.dataset.tab, false);
});
}
/* ---------------------------------------------------- copy */
function wireCopy() {
document.addEventListener('click', async (event) => {
const btn = event.target.closest('[data-copy]');
if (!btn) return;
try { await navigator.clipboard.writeText(btn.dataset.copy); toast('Скопировано'); } catch (_) { toast('Не удалось скопировать', 'warning'); }
});
}
/* ---------------------------------------------------- live VM status */
function applyVmState(vm) {
$$(`[data-live-vm="${CSS.escape(vm.name)}"]`).forEach((root) => {
const [label, tone] = vmState(vm.state);
$$('[data-live="badge"]', root).forEach((el) => { el.className = `badge ${tone}${el.classList.contains('lg') ? ' lg' : ''}`; el.innerHTML = badge([label, tone]); });
$$('[data-live="dot"]', root).forEach((el) => { el.className = `status-dot ${tone}`; });
$$('[data-live="ip"]', root).forEach((el) => { if (vm.ip && vm.ip !== '—') el.textContent = vm.ip; });
const wasRunning = root.dataset.running === '1';
const nowRunning = tone === 'success';
if (root.dataset.reloadOnChange !== undefined && wasRunning !== nowRunning && !document.querySelector('details.menu[open]')) location.reload();
});
}
async function refreshLive() {
if (document.hidden) return;
try {
const response = await fetch('/live/status', { cache: 'no-store', headers: { Accept: 'application/json' } });
if (!response.ok) return;
const payload = await response.json();
if (!payload.ok) return;
payload.vms.forEach(applyVmState);
const running = payload.vms.filter((vm) => vmState(vm.state)[1] === 'success').length;
$$('[data-live-count="running"]').forEach((el) => { el.textContent = running; });
} catch (_) {}
}
function wireLive() {
if (!$('[data-live-vm]')) return;
setInterval(refreshLive, 5000);
}
/* ---------------------------------------------------- uploads */
function wireUploader(form) {
const input = $('input[type=file]', form);
const zone = $('.dropzone', form);
const fileBox = $('[data-upload-file]', form);
const nameEl = $('[data-upload-name]', form);
const sizeEl = $('[data-upload-size]', form);
const bar = $('[data-upload-progress] > span', form);
const progress = $('[data-upload-progress]', form);
const statusEl = $('[data-upload-status]', form);
const statsEl = $('[data-upload-stats]', form);
const errorEl = $('[data-upload-error]', form);
const submit = $('[data-upload-submit]', form);
const cancel = $('[data-upload-cancel]', form);
const accept = (form.dataset.accept || '').split(',').map((item) => item.trim().toLowerCase()).filter(Boolean);
let xhr = null;
const valid = (file) => file && (!accept.length || accept.some((ext) => file.name.toLowerCase().endsWith(ext)));
const setError = (message) => { errorEl.hidden = !message; $('[data-upload-error-text]', errorEl).textContent = message || ''; };
const setActive = (active) => {
window.VirtualityUploadActive = active;
submit.hidden = active; cancel.hidden = !active; input.disabled = active;
zone.hidden = active || Boolean(input.files[0]);
};
const showFile = () => {
const file = input.files[0];
setError('');
if (!file) { fileBox.hidden = true; zone.hidden = false; submit.disabled = true; return; }
if (!valid(file)) {
setError(`Файл «${file.name}» не подходит. Допустимые форматы: ${accept.join(', ')}`);
input.value = ''; fileBox.hidden = true; zone.hidden = false; submit.disabled = true; return;
}
nameEl.textContent = file.name; sizeEl.textContent = bytes(file.size);
fileBox.hidden = false; zone.hidden = true; progress.hidden = true; statsEl.textContent = '';
statusEl.textContent = 'Готов к загрузке'; submit.disabled = false;
};
input.addEventListener('change', showFile);
['dragenter', 'dragover'].forEach((type) => zone.addEventListener(type, (e) => { e.preventDefault(); zone.classList.add('dragover'); }));
['dragleave', 'drop'].forEach((type) => zone.addEventListener(type, () => zone.classList.remove('dragover')));
zone.addEventListener('drop', (e) => { e.preventDefault(); if (e.dataTransfer.files.length) { input.files = e.dataTransfer.files; showFile(); } });
$('[data-upload-clear]', form)?.addEventListener('click', () => { input.value = ''; showFile(); });
cancel.addEventListener('click', () => xhr && xhr.abort());
window.addEventListener('beforeunload', (event) => { if (window.VirtualityUploadActive) { event.preventDefault(); event.returnValue = ''; } });
async function pollOperation(id) {
statusEl.textContent = 'Файл загружен. Готовим образ…';
bar.style.width = '0%';
for (;;) {
await new Promise((r) => setTimeout(r, 1200));
try {
const response = await fetch(`/api/operations/${id}`, { cache: 'no-store' });
const data = await response.json();
const op = data.operation || {};
bar.style.width = `${op.progress || 0}%`;
statusEl.textContent = `Конвертация в формат qcow2 — ${op.progress || 0}%`;
if (op.status === 'success') return true;
if (op.status === 'error') { setError(op.message || 'Ошибка конвертации'); return false; }
} catch (_) {}
}
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const file = input.files[0];
if (!valid(file)) { setError('Выберите файл'); return; }
const started = Date.now();
xhr = new XMLHttpRequest();
setActive(true); progress.hidden = false; progress.classList.remove('success', 'danger');
statusEl.textContent = 'Загрузка на сервер…';
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const pct = Math.round((e.loaded / e.total) * 100);
const speed = e.loaded / Math.max(1, (Date.now() - started) / 1000);
const left = speed > 0 ? Math.round((e.total - e.loaded) / speed) : 0;
bar.style.width = `${pct}%`;
statsEl.textContent = `${bytes(e.loaded)} из ${bytes(e.total)} · ${bytes(speed)}/с${left > 2 ? ` · осталось ~${left > 90 ? Math.round(left / 60) + ' мин' : left + ' с'}` : ''}`;
statusEl.textContent = pct >= 100 ? 'Сохраняем файл на сервере…' : `Загрузка на сервер — ${pct}%`;
};
xhr.onload = async () => {
if (xhr.status >= 200 && xhr.status < 400) {
let payload = null;
try { payload = JSON.parse(xhr.responseText); } catch (_) {}
if (payload && payload.mode === 'converting' && payload.operation_id) {
const ok = await pollOperation(payload.operation_id);
window.VirtualityUploadActive = false;
if (!ok) { setActive(false); progress.classList.add('danger'); return; }
}
window.VirtualityUploadActive = false;
progress.classList.add('success'); bar.style.width = '100%'; statusEl.textContent = 'Готово';
sessionStorage.setItem('virtualityToast', JSON.stringify({ message: `Файл ${file.name} загружен`, tone: 'success' }));
location.href = form.dataset.redirect || location.pathname;
} else {
setActive(false); progress.classList.add('danger');
const doc = new DOMParser().parseFromString(xhr.responseText || '', 'text/html');
setError(doc.querySelector('[data-error-message]')?.textContent.trim() || `Сервер ответил ошибкой (${xhr.status})`);
statusEl.textContent = 'Загрузка не удалась';
}
};
xhr.onerror = () => { setActive(false); setError('Связь с сервером прервалась. Попробуйте ещё раз.'); };
xhr.onabort = () => { setActive(false); statusEl.textContent = 'Загрузка отменена'; bar.style.width = '0%'; statsEl.textContent = ''; };
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.send(new FormData(form));
});
showFile();
}
/* ---------------------------------------------------- operation page */
function wireOperation() {
const root = $('[data-operation-id]');
if (!root) return;
const id = root.dataset.operationId;
const statusEl = $('[data-op="status"]', root);
const bar = $('[data-op="bar"]', root);
const progress = $('[data-op="progress"]', root);
const pctEl = $('[data-op="pct"]', root);
const messageEl = $('[data-op="message"]', root);
const logEl = $('[data-op="log"]');
let shown = Number(root.dataset.progress || 0);
let status = root.dataset.status;
const done = () => status === 'success' || status === 'error';
const render = () => {
bar.style.width = `${Math.round(shown)}%`;
pctEl.textContent = `${Math.round(shown)}%`;
progress.classList.toggle('success', status === 'success');
progress.classList.toggle('danger', status === 'error');
};
const tick = setInterval(() => {
if (done()) return;
shown = Math.min(status === 'queued' ? 10 : 95, shown + (shown > 80 ? 0.15 : 0.5));
render();
}, 1000);
const poll = async () => {
try {
const response = await fetch(`/api/operations/${id}`, { cache: 'no-store' });
const data = await response.json();
if (!data.ok) return;
const op = data.operation;
const wasDone = done();
status = op.status;
shown = status === 'success' ? 100 : Math.max(shown, Number(op.progress || 0));
const [label, tone] = opState(status);
statusEl.className = `badge lg ${tone}`; statusEl.innerHTML = badge([label, tone]);
messageEl.textContent = op.message || '';
if (logEl) { const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; logEl.textContent = op.log_tail || ''; if (atBottom) logEl.scrollTop = logEl.scrollHeight; }
render();
if (done()) { clearInterval(timer); clearInterval(tick); if (!wasDone) { toast(status === 'success' ? 'Операция завершена' : 'Операция завершилась с ошибкой', status === 'success' ? 'success' : 'danger'); $$('[data-op-done]').forEach((el) => { el.hidden = false; }); } }
} catch (_) {}
};
const timer = setInterval(poll, 1500);
render(); poll();
if (logEl) logEl.scrollTop = logEl.scrollHeight;
}
/* ---------------------------------------------------- logs & update pollers */
function wireLogs() {
const box = $('[data-log-source]');
if (!box) return;
const refresh = async () => {
if (document.hidden || !$('[data-log-live]')?.checked) return;
try {
const response = await fetch(`/api/logs?source=${encodeURIComponent(box.dataset.logSource)}&lines=${encodeURIComponent(box.dataset.logLines)}`, { cache: 'no-store' });
const data = await response.json();
if (!data.ok) return;
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
box.textContent = data.log.content || 'Журнал пуст';
if (atBottom) box.scrollTop = box.scrollHeight;
} catch (_) {}
};
box.scrollTop = box.scrollHeight;
setInterval(refresh, 4000);
}
function wireUpdate() {
const logBox = $('[data-update-log]');
if (!logBox) return;
const stateEl = $('[data-update-state]');
const poll = async () => {
try {
const response = await fetch('/update/status', { cache: 'no-store' });
const data = await response.json();
if (!data.ok) return;
logBox.textContent = data.log_tail || 'Журнал пока пуст.';
const state = data.state || {};
if (stateEl) stateEl.textContent = state.message || '';
if (state.status === 'running') { $$('[data-update-running]').forEach((el) => { el.hidden = false; }); }
} catch (_) {}
};
poll();
setInterval(poll, 2500);
}
/* ---------------------------------------------------- VM wizard */
function wireWizard() {
const form = $('form[data-wizard]');
if (!form) return;
const field = (name) => form.elements[name];
const value = (name) => { const el = field(name); if (!el) return ''; if (el instanceof RadioNodeList) return el.value; return el.value; };
const setSummary = (key, text) => $$(`[data-summary="${key}"]`).forEach((el) => { el.textContent = text || '—'; });
const optionText = (select) => select && select.selectedIndex >= 0 ? select.options[select.selectedIndex].dataset.label || select.options[select.selectedIndex].text : '';
function sync() {
const source = value('source_type');
$$('[data-show-when]', form).forEach((el) => {
const [key, expected] = el.dataset.showWhen.split('=');
const visible = value(key) === expected;
el.hidden = !visible;
$$('input, select', el).forEach((input) => { input.disabled = !visible; });
});
$$('[data-visible-when]', form).forEach((el) => {
const [key, expected] = el.dataset.visibleWhen.split('=');
el.hidden = value(key) !== expected;
});
const preset = value('preset');
const custom = preset === 'custom';
const presetInput = $(`input[name=preset][value="${preset}"]`, form);
if (!custom && presetInput) {
field('vcpus').value = presetInput.dataset.vcpus;
field('memory').value = presetInput.dataset.memory;
field('disk_size').value = presetInput.dataset.disk;
}
$$('[data-custom-resources] input', form).forEach((input) => { input.readOnly = !custom; });
const memory = Number(value('memory')) || 0;
setSummary('name', value('name'));
setSummary('source', source === 'disk_image' ? optionText(field('disk_image_path')) : optionText(field('iso_path')));
setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`);
setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`);
setSummary('disk', source === 'disk_image' ? 'из образа' : `${value('disk_size')} ГБ`);
setSummary('network', value('network_mode') === 'bridge' ? 'Локальная сеть' : 'Автоматически (NAT)');
const hasSource = source === 'disk_image' ? form.dataset.hasDisks === '1' : form.dataset.hasIsos === '1';
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
}
form.addEventListener('input', sync);
form.addEventListener('change', sync);
sync();
}
/* ---------------------------------------------------- boot order list */
function wireBootOrder() {
const list = $('[data-boot-list]');
if (!list) return;
const input = $('[data-boot-value]');
const orders = { cdrom_disk: ['cdrom', 'hd', 'network'], disk_cdrom: ['hd', 'cdrom', 'network'], network_disk: ['network', 'hd', 'cdrom'], disk: ['hd', 'cdrom', 'network'], auto: ['hd', 'cdrom', 'network'] };
(orders[list.dataset.current] || orders.auto).forEach((device) => { const node = $(`[data-device="${device}"]`, list); if (node) list.appendChild(node); });
const update = () => {
const devices = $$('[data-device]', list).map((item) => item.dataset.device);
const [first, second] = devices;
input.value = first === 'cdrom' && second === 'hd' ? 'cdrom_disk' : first === 'hd' && second === 'cdrom' ? 'disk_cdrom' : first === 'network' && second === 'hd' ? 'network_disk' : first === 'hd' ? 'disk' : 'auto';
$$('[data-device]', list).forEach((item, index) => { $('[data-position]', item).textContent = index + 1; });
};
list.addEventListener('click', (event) => {
const btn = event.target.closest('[data-move]');
if (!btn) return;
const item = btn.closest('[data-device]');
if (btn.dataset.move === 'up' && item.previousElementSibling) list.insertBefore(item, item.previousElementSibling);
if (btn.dataset.move === 'down' && item.nextElementSibling) list.insertBefore(item.nextElementSibling, item);
update();
});
update();
}
document.addEventListener('DOMContentLoaded', () => {
wireTheme();
wireNav();
wireForms();
wireTabs();
wireCopy();
wireLive();
$$('form[data-uploader]').forEach(wireUploader);
wireOperation();
wireLogs();
wireUpdate();
wireWizard();
wireBootOrder();
flashToast();
});
})();
+12
Просмотреть файл
@@ -0,0 +1,12 @@
{% from "_ui.html" import icon %}
<div class="page-header">
<div>
<h1>Образы ОС</h1>
<p class="lead">Файлы, из которых создаются машины. Загрузите один раз — используйте для любого количества машин.</p>
</div>
<div class="page-actions"><a class="btn btn-primary" href="/vm/create">{{ icon('plus') }}Создать машину</a></div>
</div>
<nav class="tabs">
<a href="/iso" class="{{ 'active' if request.url.path.startswith('/iso') else '' }}">{{ icon('disc-3', 'icon-sm') }}Установочные образы{% if isos is defined %}<span class="tag">{{ isos|length }}</span>{% endif %}</a>
<a href="/disk-images" class="{{ 'active' if request.url.path.startswith('/disk-images') else '' }}">{{ icon('hard-drive', 'icon-sm') }}Готовые системы{% if images is defined %}<span class="tag">{{ images|length }}</span>{% endif %}</a>
</nav>
+68
Просмотреть файл
@@ -0,0 +1,68 @@
{% macro icon(name, cls='') -%}
<svg class="icon {{ cls }}" aria-hidden="true"><use href="/static/icons.svg#i-{{ name }}"></use></svg>
{%- endmacro %}
{% macro logo() -%}
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4.5 5.5 12 19l7.5-13.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 5.5h6" stroke="#fff" stroke-opacity=".55" stroke-width="2.6" stroke-linecap="round"/></svg>
{%- endmacro %}
{% macro badge(label, tone='neutral', dot=True, cls='', attrs='') -%}
<span class="badge {{ tone }} {{ cls }}" {{ attrs|safe }}>{% if dot %}<span class="dot"></span>{% endif %}{{ label }}</span>
{%- endmacro %}
{% macro vm_badge(state, cls='') -%}
{%- set s = state|vm_state -%}
<span class="badge {{ s.tone }} {{ cls }}" data-live="badge"><span class="dot"></span>{{ s.label }}</span>
{%- endmacro %}
{% macro op_badge(status, cls='') -%}
{%- set s = status|operation_state -%}
<span class="badge {{ s.tone }} {{ cls }}"><span class="dot"></span>{{ s.label }}</span>
{%- endmacro %}
{% macro meter(pct, tone=None) -%}
<div class="meter {{ tone or (pct|level_tone) }}"><span style="--value: {{ pct }}%"></span></div>
{%- endmacro %}
{% macro empty(icon_name, title, text, action_url=None, action_label=None, action_icon='plus') -%}
<div class="empty">
<div class="empty-art">{{ icon(icon_name, 'icon-xl') }}</div>
<h3>{{ title }}</h3>
<p>{{ text }}</p>
{% if action_url %}<div class="row"><a class="btn btn-primary" href="{{ action_url }}">{{ icon(action_icon) }}{{ action_label }}</a></div>{% endif %}
</div>
{%- endmacro %}
{% macro alert(tone, text, title=None, icon_name=None) -%}
{%- set icons = {'success': 'circle-check', 'danger': 'circle-x', 'warning': 'triangle-alert', 'info': 'info', 'brand': 'sparkles'} -%}
<div class="alert {{ tone }}" role="{{ 'alert' if tone == 'danger' else 'status' }}">
{{ icon(icon_name or icons.get(tone, 'info')) }}
<div>{% if title %}<strong>{{ title }}</strong>{% endif %}<span {% if tone == 'danger' %}data-error-message{% endif %}>{{ text }}</span></div>
</div>
{%- endmacro %}
{% macro uploader(action, field, accept, title, hint, redirect, button) -%}
<form method="post" action="{{ action }}" enctype="multipart/form-data" class="stack-sm" data-uploader data-accept="{{ accept }}" data-redirect="{{ redirect }}">
<label class="dropzone">
<input type="file" name="{{ field }}" accept="{{ accept }}" required>
<span class="dropzone-icon">{{ icon('cloud-upload', 'icon-lg') }}</span>
<strong>{{ title }}</strong>
<span>{{ hint }}</span>
</label>
<div class="upload-file" data-upload-file hidden>
<span class="list-icon brand">{{ icon('package') }}</span>
<div class="grow">
<div class="row-between"><strong class="truncate" data-upload-name></strong><span class="subtle small" data-upload-size></span></div>
<div class="subtle small mt-1" data-upload-status></div>
<div class="progress mt-1" data-upload-progress hidden><span></span></div>
<div class="upload-stats" data-upload-stats></div>
</div>
<button type="button" class="btn btn-ghost btn-icon btn-sm" data-upload-clear title="Убрать файл">{{ icon('x') }}</button>
</div>
<div class="alert danger" data-upload-error hidden>{{ icon('circle-x') }}<div data-upload-error-text></div></div>
<div class="row">
<button type="submit" class="btn btn-primary" data-upload-submit disabled>{{ icon('cloud-upload') }}{{ button }}</button>
<button type="button" class="btn btn-danger-soft" data-upload-cancel hidden>{{ icon('x') }}Отменить загрузку</button>
</div>
</form>
{%- endmacro %}
+94
Просмотреть файл
@@ -0,0 +1,94 @@
{% from "_ui.html" import icon, logo, alert %}
{%- set path = request.url.path -%}
{%- macro nav_link(href, icon_name, label, active, extra='') -%}
<a href="{{ href }}" class="{{ 'active' if active else '' }}" {% if active %}aria-current="page"{% endif %}>{{ icon(icon_name) }}<span>{{ label }}</span>{{ extra|safe }}</a>
{%- endmacro -%}
<!doctype html>
<html lang="ru" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{% block title %}{{ page_title|default('Панель') }}{% endblock %} — Virtuality</title>
<script>
(function () {
var mode = 'system';
try { mode = localStorage.getItem('virtualityTheme') || 'system'; } catch (e) {}
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
})();
</script>
<link rel="preload" href="/static/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/app.css?v={{ app_version }}">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%237b6bff'/%3E%3Cstop offset='1' stop-color='%232f73f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='9' fill='url(%23g)'/%3E%3Cpath d='M9 10l7 13 7-13' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
{% block head %}{% endblock %}
</head>
<body>
{%- set notice = update_notice() -%}
<div class="app">
<aside class="sidebar" id="sidebar" aria-label="Главное меню">
<a class="logo" href="/">
<span class="brand-mark">{{ logo() }}</span>
<span class="brand-name">Virtuality<small>Виртуализация · {{ app_version }}</small></span>
</a>
<nav class="nav">
{{ nav_link('/', 'layout-dashboard', 'Обзор', path == '/') }}
{{ nav_link('/vm/create', 'plus', 'Создать машину', path.startswith('/vm/create')) }}
<div class="nav-section">Ресурсы</div>
{{ nav_link('/iso', 'disc-3', 'Образы ОС', path.startswith('/iso') or path.startswith('/disk-images')) }}
{{ nav_link('/network', 'globe', 'Сеть и доступ', path.startswith('/network')) }}
<div class="nav-section">Система</div>
{{ nav_link('/operations', 'list-checks', 'Задачи', path.startswith('/operations')) }}
{{ nav_link('/logs', 'scroll-text', 'Журналы', path.startswith('/logs')) }}
{{ nav_link('/update', 'refresh-cw', 'Обновления', path.startswith('/update'), '<span class="nav-dot" title="Доступно обновление"></span>' if notice.has_update else '') }}
{{ nav_link('/host', 'server', 'Сервер', path.startswith('/host')) }}
{{ nav_link('/help', 'circle-help', 'Помощь', path.startswith('/help')) }}
</nav>
<div class="sidebar-footer">
<div class="server-chip">
<span class="avatar">{{ (user or '?')[:1]|upper }}</span>
<div><strong>{{ user }}</strong><span>Администратор</span></div>
</div>
<div class="sidebar-actions">
<button type="button" class="btn btn-sm btn-icon" data-theme-toggle title="Сменить тему">{{ icon('moon') }}</button>
<form method="post" action="/logout" data-no-busy><button type="submit" class="btn btn-sm" style="flex:1">{{ icon('log-out', 'icon-sm') }}Выйти</button></form>
</div>
</div>
</aside>
<div class="main">
<header class="mobilebar">
<button type="button" class="btn btn-icon btn-ghost" data-nav-toggle aria-label="Открыть меню">{{ icon('menu') }}</button>
<a class="logo" href="/"><span class="brand-mark">{{ logo() }}</span><span class="brand-name">Virtuality</span></a>
<button type="button" class="btn btn-icon btn-ghost" data-theme-toggle aria-label="Сменить тему">{{ icon('moon') }}</button>
</header>
<main class="content">
{% block page_header %}{% endblock %}
<div class="alerts">{% block alerts %}{% if error %}{{ alert('danger', error) }}{% endif %}{% endblock %}</div>
{% block content %}{% endblock %}
</main>
</div>
</div>
<dialog class="dialog" id="confirm-dialog">
<form method="dialog">
<div class="dialog-body">
<div class="dialog-icon">{{ icon('triangle-alert', 'icon-lg') }}</div>
<div><h3 data-confirm-title>Подтвердите действие</h3><p data-confirm-text></p></div>
</div>
<div class="dialog-actions">
<button class="btn" value="cancel">Отмена</button>
<button class="btn btn-danger" type="button" data-confirm-ok>Продолжить</button>
</div>
</form>
</dialog>
<div id="toasts" aria-live="polite"></div>
<script src="/static/panel.js?v={{ app_version }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>
+29 -40
Просмотреть файл
@@ -1,48 +1,37 @@
{% from "_ui.html" import icon, logo, vm_badge %}
<!doctype html>
<html lang="ru">
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Консоль {{ vm.name }} — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
<title>Экран {{ vm.name }} — Virtuality</title>
<link rel="stylesheet" href="/static/app.css?v={{ app_version }}">
</head>
<body class="console-body">
<div class="console-window">
<header class="console-topbar">
<div class="console-title">
<strong>{{ vm.name }}</strong>
<span>VNC {{ console.display }}{% if console.port %} · port {{ console.port }}{% endif %}</span>
<body class="console-page">
<header class="console-bar">
<div class="row">
<span class="brand-mark" style="width:28px;height:28px;border-radius:8px">{{ logo() }}</span>
<strong>{{ vm.name }}</strong>
{{ vm_badge(vm.info.state) }}
</div>
<div class="row">
<span class="small" style="color:#8b95a7">Чтобы вернуть курсор, нажмите Ctrl+Alt</span>
<a class="btn btn-sm" href="/vm/{{ vm.name }}">{{ icon('settings', 'icon-sm') }}Настройки машины</a>
</div>
</header>
{% if console.url %}
<iframe class="console-frame" src="{{ console.url }}" allowfullscreen title="Экран машины {{ vm.name }}"></iframe>
{% else %}
<main style="flex:1;display:grid;place-items:center;padding:24px">
<div class="card" style="max-width:480px;background:#11141b;border-color:#232935">
<div class="empty">
<div class="empty-art">{{ icon('monitor', 'icon-xl') }}</div>
<h3 style="color:#edf0f6">Экран недоступен</h3>
<p style="color:#a9b1c2">{% if not console.has_novnc %}На сервере не установлен компонент noVNC. Переустановите панель: <code>sudo bash scripts/install_web_panel.sh</code>{% else %}Машина выключена или ещё загружается. Запустите её и обновите страницу.{% endif %}</p>
<div class="row"><a class="btn btn-primary" href="/vm/{{ vm.name }}">К машине</a><button class="btn" onclick="location.reload()">Обновить</button></div>
</div>
</div>
<div class="console-actions">
<span class="status {{ 'ok' if console.url else 'err' }}">{{ 'connected ready' if console.url else 'not ready' }}</span>
<a class="link-pill" href="/vm/{{ vm.name }}">VM</a>
<a class="link-pill" href="/">Дашборд</a>
</div>
</header>
{% if console.url %}
<main class="console-main">
<iframe class="console-frame-full" src="{{ console.url }}" allowfullscreen></iframe>
</main>
{% else %}
<main class="console-main console-error-wrap">
<section class="card console-error-card">
<div class="card-head">
<h2>Консоль пока недоступна</h2>
<span class="status err">not ready</span>
</div>
<div class="services">
<div class="service-row"><span>noVNC path</span><span>{{ console.novnc_dir }}</span></div>
<div class="service-row"><span>VNC display</span><span>{{ console.display }}</span></div>
<div class="service-row"><span>VNC port</span><span>{{ console.port or 'not available' }}</span></div>
<div class="service-row"><span>VM IP</span><span>{{ vm.ip }}</span></div>
</div>
<div class="notice top-space">
Проверь, что VM запущена, у неё есть VNC display, а пакет novnc установлен. После обновления web-панели установщик должен поставить novnc автоматически.
</div>
</section>
</main>
{% endif %}
</div>
</main>
{% endif %}
</body>
</html>
+161 -188
Просмотреть файл
@@ -1,190 +1,163 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ app_name }} Panel</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="v-layout">
{% include "_sidebar.html" %}
{% extends "base.html" %}
{% 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 %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Обзор</div>
<div class="v-subtitle">{{ system.hostname }} · {{ system.ip }} · {{ system.time }}</div>
</div>
<div class="top-meta">
<span>user: {{ user }}</span>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
{% if update_notice and update_notice.has_update %}
<section class="card update-banner">
<div>
<div class="label">Доступно обновление Virtuality</div>
<div class="value small">{{ update_notice.current_version }} → {{ update_notice.latest_version }}</div>
<div class="muted small-note">Пропущено версий: {{ update_notice.missing_count }} · новых коммитов: {{ update_notice.commit_count }}</div>
</div>
<div class="actions">
<a class="link-pill accent" href="/update">Посмотреть изменения</a>
</div>
</section>
{% endif %}
<section class="grid stats">
<article class="card stat">
<div class="label">Uptime</div>
<div class="value">{{ system.uptime }}</div>
</article>
<article class="card stat">
<div class="label">Load</div>
<div class="value">{{ system.load }}</div>
</article>
<article class="card stat">
<div class="label">Kernel</div>
<div class="value small">{{ system.kernel }}</div>
</article>
<article class="card stat">
<div class="label">VM</div>
<div class="value">{{ vms|length }}</div>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Виртуальные машины</h2>
<div class="actions">
<a class="link-pill" href="/operations">Операции</a>
<a class="link-pill" href="/logs">Журналы</a>
</div>
</div>
<table class="vm-table">
<thead>
<tr><th>ID</th><th>Name</th><th>IP</th><th>State</th><th>Autostart</th><th>Actions</th></tr>
</thead>
<tbody>
{% for vm in vms %}
{% set is_running = 'running' in vm.state %}
<tr class="vm-row {{ 'is-running' if is_running else 'is-stopped' }}">
<td>{{ vm.id }}</td>
<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
<td class="strong">{{ vm.ip|default('—') }}</td>
<td><span class="status {{ 'ok' if is_running else 'err' if 'shut' in vm.state else 'warn' }}">{{ vm.state }}</span></td>
<td class="autostart-cell">
{% if vm.autostart_enabled %}
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="inline-form">
<button class="status status-button ok" title="Отключить автозапуск">{{ vm.autostart_label|default('enabled') }}</button>
</form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/autostart" class="inline-form">
<button class="status status-button err" title="Включить автозапуск">{{ vm.autostart_label|default('disabled') }}</button>
</form>
{% endif %}
</td>
<td class="vm-actions-cell">
<div class="vm-actions compact-actions">
<a class="icon-action" href="/vm/{{ vm.name }}" title="Подробнее"><span>ℹ</span><em>Детали</em></a>
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Консоль"><span>▣</span><em>Консоль</em></a>
{% if is_running %}
<form method="post" action="/vm/{{ vm.name }}/reboot"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/shutdown"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/destroy"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power</em></button></form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/start"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><button class="icon-action danger" title="Удалить VM"><span>🗑</span><em>Delete</em></button></form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">Виртуальных машин пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="grid three dashboard-service-grid">
<article class="card">
<div class="card-head">
<h2>Сервисы</h2>
<span class="pill">systemd</span>
</div>
<div class="services">
{% for name, state in services.items() %}
<div class="service-row">
<span>{{ name }}</span>
<span class="status {{ 'ok' if state == 'active' else 'warn' }}">{{ state }}</span>
</div>
{% endfor %}
</div>
</article>
<article class="card">
<div class="card-head">
<h2>Storage pools</h2>
<span class="pill">libvirt</span>
</div>
<table>
<thead>
<tr><th>Name</th><th>State</th><th>Autostart</th></tr>
</thead>
<tbody>
{% for pool in pools %}
<tr>
<td>{{ pool.name }}</td>
<td><span class="status {{ 'ok' if pool.state == 'active' else 'warn' }}">{{ pool.state }}</span></td>
<td>{{ pool.autostart }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="muted">Storage pools не найдены</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="card">
<div class="card-head">
<h2>Последние операции</h2>
<a class="link-pill" href="/operations">Все операции</a>
</div>
<div class="services">
{% for operation in operations %}
<a class="service-row service-link" href="/operations/{{ operation.id }}">
<span>
<strong>{{ operation.title }}</strong>
<small>{{ operation.created_at }} · {{ operation.message }}</small>
</span>
<span class="status {{ operation_css(operation.status) }}">{{ operation.status }}</span>
</a>
{% else %}
<div class="muted">Операций пока нет.</div>
{% endfor %}
</div>
</article>
</section>
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Интерфейсы</h2>
<span class="pill">ip -br a</span>
</div>
<pre>{{ network.interfaces }}</pre>
</article>
<article class="card">
<div class="card-head">
<h2>Маршруты</h2>
<span class="pill">ip route</span>
</div>
<pre>{{ network.routes }}</pre>
</article>
</section>
</main>
{% block page_header %}
<div class="page-header">
<div>
<div class="eyebrow">{{ icon('server', 'icon-sm') }} {{ system.hostname }} · {{ system.ip }}</div>
<h1>{{ greeting }}, {{ user }}</h1>
<p class="lead">
{% if vms %}Работает <strong data-live-count="running">{{ running }}</strong> из {{ vms|length }} {{ plural(vms|length, 'машины', 'машин', 'машин') }}.{% else %}Сервер готов к работе — осталось создать первую виртуальную машину.{% endif %}
</p>
</div>
<script src="/static/panel.js" defer></script>
</body>
</html>
<div class="page-actions">
<a class="btn btn-primary" href="/vm/create">{{ icon('plus') }}Создать машину</a>
</div>
</div>
{% endblock %}
{% block alerts %}
{% set notice = update_notice() %}
{% if notice.has_update %}
<div class="alert brand">{{ icon('sparkles') }}<div><strong>Доступна новая версия {{ notice.latest_version }}</strong>Обновление займёт пару минут, машины продолжат работать.</div><a class="btn btn-sm btn-primary alert-action" href="/update">Посмотреть</a></div>
{% endif %}
{% if profile.virtualization_warning %}
{{ alert('warning', 'Машины будут работать медленно. Включите аппаратную виртуализацию (Intel VT-x / AMD-V) в настройках BIOS сервера.', 'Аппаратное ускорение недоступно') }}
{% endif %}
{% endblock %}
{% block content %}
<section class="grid grid-4">
<article class="card stat">
<div class="stat-head">Машины<span class="stat-icon">{{ icon('monitor', 'icon-sm') }}</span></div>
<div class="stat-value"><span data-live-count="running">{{ running }}</span><small>/ {{ vms|length }} работают</small></div>
{{ meter(((running / vms|length * 100) if vms else 0)|round|int, 'brand') }}
</article>
<article class="card stat">
<div class="stat-head">Процессор<span class="stat-icon">{{ icon('cpu', 'icon-sm') }}</span></div>
<div class="stat-value">{{ host.cpu_pct }}<small>%</small></div>
{{ meter(host.cpu_pct) }}
<div class="stat-foot">{{ host.cpu_count }} {{ plural(host.cpu_count, 'ядро', 'ядра', 'ядер') }}</div>
</article>
<article class="card stat">
<div class="stat-head">Память<span class="stat-icon">{{ icon('memory-stick', 'icon-sm') }}</span></div>
<div class="stat-value">{{ host.mem_used }}<small>из {{ host.mem_total }}</small></div>
{{ meter(host.mem_pct) }}
<div class="stat-foot">Занято {{ host.mem_pct }}%</div>
</article>
<article class="card stat">
<div class="stat-head">Хранилище<span class="stat-icon">{{ icon('hard-drive', 'icon-sm') }}</span></div>
<div class="stat-value">{{ host.disk_free }}<small>свободно</small></div>
{{ meter(host.disk_pct) }}
<div class="stat-foot">Всего {{ host.disk_total }}</div>
</article>
</section>
{% if vms %}
<section class="card mt-3">
<div class="card-header">
<div><h2>Виртуальные машины</h2><p>Нажмите на машину, чтобы открыть её настройки</p></div>
<a class="btn btn-sm" href="/vm/create">{{ icon('plus', 'icon-sm') }}Новая</a>
</div>
<div class="vm-list">
{% for vm in vms %}
{% set s = vm.state|vm_state %}
<div class="vm-row" data-live-vm="{{ vm.name }}" data-running="{{ '1' if s.running else '0' }}" data-reload-on-change>
<a class="vm-ident" href="/vm/{{ vm.name }}">
<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>
</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>
</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>
{% 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 %}
<details class="menu">
<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>
{% 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 %}
<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 %}
</div>
</details>
</div>
</div>
{% endfor %}
</div>
</section>
{% else %}
<section class="card hero-card mt-3">
<div class="row-between" style="align-items:flex-start">
<div>
<span class="badge brand" style="background:rgba(255,255,255,.12);color:#fff">{{ icon('sparkles', 'icon-sm') }}Начало работы</span>
<h2 class="mt-2">Создайте первую виртуальную машину</h2>
<p>Три шага: загрузите установочный образ системы, создайте машину и установите систему прямо в браузере.</p>
</div>
</div>
<div class="grid grid-3 mt-3">
<a class="card card-link" href="/iso" style="background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.14);color:#fff">
<div class="card-body"><span class="list-icon" style="background:rgba(255,255,255,.12);color:#fff">{{ icon('disc-3') }}</span><h3 class="mt-2" style="color:#fff">1. Загрузите образ</h3><p class="small" style="color:rgba(255,255,255,.7)">Ubuntu, Debian, Windows — файл .iso</p></div>
</a>
<a class="card card-link" href="/vm/create" style="background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.14);color:#fff">
<div class="card-body"><span class="list-icon" style="background:rgba(255,255,255,.12);color:#fff">{{ icon('plus') }}</span><h3 class="mt-2" style="color:#fff">2. Создайте машину</h3><p class="small" style="color:rgba(255,255,255,.7)">Выберите готовую конфигурацию</p></div>
</a>
<div class="card" style="background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.14);color:#fff">
<div class="card-body"><span class="list-icon" style="background:rgba(255,255,255,.12);color:#fff">{{ icon('monitor') }}</span><h3 class="mt-2" style="color:#fff">3. Откройте экран</h3><p class="small" style="color:rgba(255,255,255,.7)">Установите систему прямо в браузере</p></div>
</div>
</div>
</section>
{% endif %}
<section class="grid grid-2 mt-3">
<article class="card">
<div class="card-header"><div><h2>Последние задачи</h2><p>Создание машин, загрузка и конвертация образов</p></div><a class="btn btn-sm btn-ghost" href="/operations">Все{{ icon('chevron-right', 'icon-sm') }}</a></div>
<div class="list">
{% for op in operations %}
{% set os = op.status|operation_state %}
<a class="list-item" href="/operations/{{ op.id }}">
<span class="list-icon {{ os.tone }}">{{ icon('circle-check' if op.status == 'success' else 'circle-x' if op.status == 'error' else 'clock') }}</span>
<div class="grow"><div class="title truncate">{{ op.title }}</div><div class="meta truncate">{{ op.created_at }} · {{ op.message }}</div></div>
{{ op_badge(op.status) }}
</a>
{% else %}
<div class="empty" style="padding:32px 20px"><p class="subtle">Задач пока не было. Здесь появится прогресс создания машин и загрузки образов.</p></div>
{% endfor %}
</div>
</article>
<article class="card">
<div class="card-header">
<div><h2>Состояние сервера</h2><p>Службы, от которых зависит работа машин</p></div>
{% if services_ok %}{{ badge('Всё в порядке', 'success') }}{% else %}{{ badge('Требует внимания', 'danger') }}{% endif %}
</div>
<div class="checklist">
{% for svc in service_rows %}
<div class="check-item {{ svc.tone }}">
{{ icon('circle-check' if svc.tone == 'success' else 'triangle-alert' if svc.tone == 'warning' else 'circle-dot' if svc.tone == 'neutral' else 'circle-x') }}
<div class="row-between" style="flex:1"><strong>{{ svc.name }}</strong><span>{{ svc.label }}</span></div>
</div>
{% endfor %}
</div>
</article>
</section>
{% endblock %}
+40 -325
Просмотреть файл
@@ -1,329 +1,44 @@
<!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="v-layout">
{% include "_sidebar.html" %}
{% extends "base.html" %}
{% from "_ui.html" import icon, uploader, empty %}
{% set page_title = 'Готовые системы' %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Диски</div>
<div class="v-subtitle">Готовые образы установленных систем: .img / .raw / .qcow2 / .img.xz, а также архивы .zip / .tar.gz / .tgz</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>
{% block page_header %}{% include "_images_header.html" %}{% endblock %}
{% 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 .img.xz .zip .tar.gz</span>
{% block content %}
<section class="grid grid-main">
<article class="card">
<div class="card-header"><div><h2>Готовые системы</h2><p>Образы уже установленных систем — машина запускается сразу, без установки</p></div></div>
{% if images %}
<div class="list">
{% for image in images %}
<div class="list-item">
<span class="list-icon brand">{{ icon('hard-drive') }}</span>
<div class="grow"><div class="title truncate">{{ image.name }}</div><div class="meta">{{ image.size|replace('GB', 'ГБ') }} · формат {{ image.format }} · {{ image.updated }}</div></div>
<a class="btn btn-sm hide-sm" href="/vm/create?image={{ image.name|urlencode }}">Создать машину</a>
<form method="post" action="/disk-images/{{ image.name }}/delete" class="inline-form" data-confirm="Файл {{ image.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>
</form>
</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,.img.xz,.zip,.tar.gz,.tgz" required>
</label>
<div id="disk-file-hint" class="muted small-note">Файл ещё не выбран. Разрешены .img, .raw, .qcow2, .img.xz, .zip, .tar.gz и .tgz.</div>
<div id="disk-file-error" class="alert danger" hidden></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">Архив или .img.xz будет распакован автоматически. Virtuality найдёт внутри .img/.raw/.qcow2 или распакует .img.xz и положит результат в <b>/var/lib/virtuality/disk-images</b>. После загрузки .img/.raw/.img.xz запускается конвертация в qcow2. Установочные .iso загружаются отдельно в разделе <b>ISO</b>.</p>
</article>
{% endfor %}
</div>
{% else %}
{{ empty('hard-drive', 'Готовых образов пока нет', 'Подойдут облачные образы Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }}
{% endif %}
</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, .img.xz или архив с .img, дождись распаковки/конвертации, затем в “Создать VM” выбери режим “Готовый диск”.</span>
</div>
<div class="big-action static">
<strong>Архивы безопасно распаковываются</strong>
<span>Поддерживаются .zip, .tar.gz, .tgz и .img.xz. Из обычных архивов берём только .img/.raw/.qcow2.</span>
</div>
</div>
<pre>qemu-img convert -p -f raw -O qcow2 source.img source.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, .img.xz или архив .zip/.tar.gz/.tgz.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</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 errorBox = document.getElementById('disk-file-error');
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');
const allowedExtensions = ['.img', '.raw', '.qcow2', '.img.xz', '.zip', '.tar.gz', '.tgz'];
let activeXhr = null;
let uploadActive = false;
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];
}
function validDiskFile(file) {
if (!file || !file.name) return false;
const lower = file.name.toLowerCase();
return allowedExtensions.some(ext => lower.endsWith(ext));
}
function setFileError(message) {
if (!errorBox) return;
errorBox.textContent = message || '';
errorBox.hidden = !message;
}
function setUploadMode(active) {
uploadActive = active;
window.VirtualityUploadActive = active;
document.body.classList.toggle('upload-active', active);
if (active) {
button.disabled = false;
button.type = 'button';
button.textContent = 'Отменить загрузку';
button.classList.remove('primary');
button.classList.add('danger');
fileInput.disabled = true;
} else {
activeXhr = null;
button.type = 'submit';
button.textContent = 'Загрузить образ диска';
button.classList.add('primary');
button.classList.remove('danger');
fileInput.disabled = false;
button.disabled = !validDiskFile(fileInput.files[0]);
}
}
function resetProgress(title, detail) {
bar.style.width = '0%';
percent.textContent = '0%';
statusLine.textContent = title;
loadedLine.textContent = detail || '0%';
speedLine.textContent = 'ожидание…';
}
window.addEventListener('beforeunload', function (event) {
if (!uploadActive) return;
event.preventDefault();
event.returnValue = 'Идёт загрузка образа. Если уйти со страницы, загрузка будет прервана.';
});
document.addEventListener('click', function (event) {
if (!uploadActive) return;
const navTarget = event.target.closest('a, form button[type="submit"], form button:not([type])');
if (!navTarget || navTarget === button) return;
event.preventDefault();
event.stopPropagation();
statusLine.textContent = 'Сначала заверши или отмени текущую загрузку.';
}, true);
async function pollConvert(operationId) {
fileInput.disabled = true;
button.disabled = true;
button.textContent = 'Идёт конвертация…';
button.classList.remove('danger');
button.classList.add('primary');
resetProgress('Конвертация образа в qcow2…', '0%');
while (true) {
const response = await fetch('/api/operations/' + operationId, {cache: 'no-store'});
const payload = await response.json();
if (!payload.ok) throw new Error(payload.error || 'operation error');
const op = payload.operation;
const progress = Math.max(0, Math.min(100, Number(op.progress || 0)));
bar.style.width = progress + '%';
percent.textContent = progress + '%';
loadedLine.textContent = 'Конвертация: ' + progress + '%';
speedLine.textContent = op.status;
statusLine.textContent = op.message || 'Конвертация образа…';
if (op.status === 'success') {
bar.style.width = '100%';
percent.textContent = '100%';
statusLine.textContent = 'Конвертация завершена. Обновляем список образов…';
setUploadMode(false);
setTimeout(() => { window.location.href = '/disk-images'; }, 650);
return;
}
if (op.status === 'error') {
statusLine.textContent = 'Ошибка конвертации: ' + (op.message || 'см. журнал операций');
setUploadMode(false);
return;
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
fileInput.addEventListener('change', function () {
const file = fileInput.files[0];
setFileError('');
if (!file) {
hint.textContent = 'Файл ещё не выбран. Разрешены .img, .raw, .qcow2, .img.xz, .zip, .tar.gz и .tgz.';
button.disabled = false;
return;
}
if (!validDiskFile(file)) {
const message = 'Неверное расширение: ' + file.name + '. Разрешены только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz и .tgz.';
hint.textContent = message;
setFileError(message);
fileInput.value = '';
button.disabled = true;
return;
}
button.disabled = false;
hint.textContent = file.name + ' · ' + bytesText(file.size);
});
button.addEventListener('click', function () {
if (!uploadActive) return;
if (activeXhr) activeXhr.abort();
});
form.addEventListener('submit', function (event) {
event.preventDefault();
if (uploadActive) return;
const file = fileInput.files[0];
setFileError('');
if (!validDiskFile(file)) {
const message = 'Выбери образ установленной системы или архив: .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz.';
hint.textContent = message;
setFileError(message);
return;
}
const xhr = new XMLHttpRequest();
activeXhr = xhr;
const data = new FormData(form);
const started = Date.now();
box.hidden = false;
setUploadMode(true);
resetProgress('Передача файла на сервер…', '0 MB из ' + bytesText(file.size));
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 = async function () {
if (xhr.status >= 200 && xhr.status < 400) {
let payload = null;
try { payload = JSON.parse(xhr.responseText); } catch (error) {}
if (payload && payload.mode === 'converting' && payload.operation_id) {
await pollConvert(payload.operation_id);
return;
}
bar.style.width = '100%';
percent.textContent = '100%';
statusLine.textContent = 'Готово. Обновляем список образов…';
setUploadMode(false);
window.location.href = '/disk-images';
} else {
statusLine.textContent = 'Ошибка загрузки: HTTP ' + xhr.status;
setUploadMode(false);
}
};
xhr.onabort = function () {
statusLine.textContent = 'Загрузка отменена пользователем.';
bar.style.width = '0%';
percent.textContent = '0%';
loadedLine.textContent = '0 MB';
speedLine.textContent = 'отменено';
setUploadMode(false);
};
xhr.onerror = function () {
statusLine.textContent = 'Ошибка сети во время загрузки.';
setUploadMode(false);
};
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.send(data);
});
</script>
<script src="/static/panel.js" defer></script>
</body>
</html>
<aside class="stack">
<article class="card">
<div class="card-header"><h2>Загрузить образ</h2></div>
<div class="card-body">
{{ uploader('/disk-images/upload', 'image_file', '.qcow2,.img,.raw,.img.xz,.zip,.tar.gz,.tgz', 'Перетащите файл образа сюда', '.qcow2, .img, .raw, а также архивы .img.xz, .zip, .tar.gz', '/disk-images', 'Загрузить') }}
</div>
</article>
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('sparkles') }}<h3>Всё делается автоматически</h3></div>
<p class="muted small">Архивы распаковываются, а образы .img и .raw конвертируются в быстрый формат qcow2. Прогресс виден прямо здесь и в разделе «Задачи».</p>
</div>
</article>
</aside>
</section>
{% endblock %}
+74
Просмотреть файл
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% from "_ui.html" import icon %}
{% set page_title = 'Помощь' %}
{% block page_header %}
<div class="page-header">
<div>
<h1>Помощь</h1>
<p class="lead">Короткие инструкции для частых задач. Командная строка не понадобится.</p>
</div>
</div>
{% endblock %}
{% block content %}
<section class="grid grid-2">
<article class="card help-card"><div class="card-body">
<div class="row"><span class="list-icon brand">{{ icon('plus') }}</span><h3>Как создать машину</h3></div>
<ol>
<li>Скачайте установочный образ системы (.iso) на сайте разработчика — например, Ubuntu Server.</li>
<li>Откройте <a href="/iso">Образы ОС</a> и перетащите файл в окно загрузки.</li>
<li>Нажмите <a href="/vm/create">Создать машину</a>, выберите образ и конфигурацию.</li>
<li>Когда задача завершится, нажмите «Открыть экран» и установите систему как на обычный компьютер.</li>
</ol>
</div></article>
<article class="card help-card"><div class="card-body">
<div class="row"><span class="list-icon brand">{{ icon('globe') }}</span><h3>Как открыть сайт или SSH из интернета</h3></div>
<ol>
<li>Откройте <a href="/network">Сеть и доступ</a>.</li>
<li>В блоке «Открыть доступ» выберите машину и сервис, например «Сайт (HTTPS)».</li>
<li>Нажмите «Открыть доступ». Теперь адрес <code>{{ host_ip }}:порт</code> ведёт в машину.</li>
<li>Если не открывается — нажмите «Проверить» рядом с правилом: панель подскажет, в чём проблема.</li>
</ol>
</div></article>
<article class="card help-card"><div class="card-body">
<div class="row"><span class="list-icon brand">{{ icon('monitor') }}</span><h3>Экран машины</h3></div>
<ul>
<li>Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.</li>
<li>Если курсор «застрял» внутри экрана, нажмите <code>Ctrl + Alt</code>.</li>
<li>После установки системы извлеките установочный диск на странице машины → «Диски и загрузка».</li>
</ul>
</div></article>
<article class="card help-card"><div class="card-body">
<div class="row"><span class="list-icon brand">{{ icon('power') }}</span><h3>Выключение и автозапуск</h3></div>
<ul>
<li><b>Выключить</b> — аккуратно завершает работу системы, как кнопка питания.</li>
<li><b>Выключить принудительно</b> — мгновенно, как выдернуть шнур. Используйте, только если машина зависла.</li>
<li><b>Автозапуск</b> — машина сама включится после перезагрузки сервера.</li>
</ul>
</div></article>
<article class="card help-card"><div class="card-body">
<div class="row"><span class="list-icon brand">{{ icon('cpu') }}</span><h3>Сколько ресурсов выделять</h3></div>
<ul>
<li><b>Мини</b> (1 ядро, 1 ГБ) — VPN, роутер, небольшие сервисы.</li>
<li><b>Стандарт</b> (2 ядра, 4 ГБ) — сайт, база данных, Linux-сервер.</li>
<li><b>Мощная</b> (4 ядра, 8 ГБ) — Windows, 1С, игровые серверы.</li>
<li>Процессор и память можно поменять позже, когда машина выключена.</li>
</ul>
</div></article>
<article class="card help-card"><div class="card-body">
<div class="row"><span class="list-icon brand">{{ icon('wrench') }}</span><h3>Если что-то не работает</h3></div>
<ul>
<li>Проверьте раздел <a href="/host">Сервер</a> — там видно, чего не хватает.</li>
<li>Посмотрите <a href="/operations">Задачи</a> — у каждой есть подробный журнал.</li>
<li>Полная диагностика в терминале сервера: <code>sudo vhealth</code>.</li>
<li>Расширенное управление доступно в Cockpit: <a href="https://{{ host_ip }}:9090" target="_blank" rel="noopener">https://{{ host_ip }}:9090</a>.</li>
</ul>
</div></article>
</section>
{% endblock %}
+54 -94
Просмотреть файл
@@ -1,96 +1,56 @@
<!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="v-layout">
{% include "_sidebar.html" %}
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, alert %}
{% set page_title = 'Сервер' %}
{% set problems = profile.checks|selectattr('level', 'ne', 'ok')|list %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Профиль хоста</div>
<div class="v-subtitle">Автоматически выбранная сборка Virtuality</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 profile.virtualization_warning %}
<div class="notice">{{ profile.virtualization_warning }}</div>
{% endif %}
<section class="grid stats">
<article class="card stat">
<div class="label">Profile</div>
<div class="value small">{{ profile.label }}</div>
</article>
<article class="card stat">
<div class="label">Arch</div>
<div class="value small">{{ profile.arch }}</div>
</article>
<article class="card stat">
<div class="label">Virtualization</div>
<div class="value small">{{ profile.virtualization_mode|default('unknown') }}</div>
</article>
<article class="card stat">
<div class="label">Default network</div>
<div class="value small">{{ profile.recommended_network }}</div>
</article>
</section>
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Модель устройства</h2>
<span class="pill">auto-detected</span>
</div>
<div class="meta-grid">
<div><span>Model</span><strong>{{ profile.model }}</strong></div>
<div><span>VM mode</span><strong>{{ profile.recommended_vm_mode }}</strong></div>
<div><span>Ready</span><strong>{{ 'yes' if profile.ready else 'needs check' }}</strong></div>
</div>
<div class="notice top-space">
На физическом сервере для нормальной скорости нужен KVM: включи Intel VT-x / AMD-V в BIOS/UEFI. Если KVM недоступен, Virtuality может использовать QEMU fallback, но VM будут работать медленно.
</div>
</article>
<article class="card">
<div class="card-head">
<h2>Проверки</h2>
<form method="post" action="/host/refresh"><button>Переопределить профиль</button></form>
</div>
<div class="services">
{% for check in profile.checks %}
{% set level = check.level|default('ok' if check.ok else 'err') %}
<div class="service-row">
<span>
<strong>{{ check.name }}</strong>
<small>{{ check.hint }}</small>
</span>
<span class="status {{ 'ok' if level == 'ok' else 'warn' if level == 'warn' else 'err' }}">{{ 'OK' if level == 'ok' else 'WARN' if level == 'warn' else 'FAIL' }}</span>
</div>
{% endfor %}
</div>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Raw profile</h2>
<span class="pill">/var/lib/virtuality/config/host_profile.json</span>
</div>
<pre>{{ profile_json }}</pre>
</section>
</main>
{% block page_header %}
<div class="page-header">
<div>
<h1>Сервер</h1>
<p class="lead">Оборудование и готовность сервера к работе с виртуальными машинами.</p>
</div>
<script src="/static/panel.js" defer></script>
</body>
</html>
<div class="page-actions">
<form method="post" action="/host/refresh" data-toast="Проверка выполнена"><button class="btn">{{ icon('refresh-cw', 'icon-sm') }}Проверить заново</button></form>
</div>
</div>
{% endblock %}
{% block content %}
<section class="grid grid-main">
<div class="stack">
<article class="card">
<div class="card-header">
<div><h2>Готовность</h2><p>{{ 'Всё готово для запуска машин' if not problems else 'Есть замечания — ниже написано, что сделать' }}</p></div>
{% if profile.ready and not problems %}{{ badge('Готов', 'success') }}{% elif profile.ready %}{{ badge('Работает с замечаниями', 'warning') }}{% else %}{{ badge('Не готов', 'danger') }}{% endif %}
</div>
<div class="checklist">
{% for check in profile.checks %}
{% set level = check.level|default('ok' if check.ok else 'err') %}
{% set tone = 'success' if level == 'ok' else 'warning' if level == 'warn' else 'danger' %}
<div class="check-item {{ tone }}">
{{ icon('circle-check' if tone == 'success' else 'triangle-alert' if tone == 'warning' else 'circle-x') }}
<div><strong>{{ check.name }}</strong>{% if tone != 'success' %}<span>{{ check.hint }}</span>{% endif %}</div>
</div>
{% endfor %}
</div>
</article>
</div>
<aside class="stack">
<article class="card">
<div class="card-header"><h2>Оборудование</h2></div>
<div class="card-body" style="padding-top:6px;padding-bottom:6px">
<div class="summary-row"><span>Тип</span><strong>{{ profile.label }}</strong></div>
<div class="summary-row"><span>Модель</span><strong>{{ profile.model }}</strong></div>
<div class="summary-row"><span>Архитектура</span><strong>{{ profile.arch }}</strong></div>
<div class="summary-row"><span>Ускорение</span><strong>{{ 'KVM (аппаратное)' if profile.virtualization_mode == 'kvm' else 'Эмуляция (медленно)' }}</strong></div>
<div class="summary-row"><span>Сеть по умолчанию</span><strong>{{ 'Локальная сеть' if profile.recommended_network == 'bridge' else 'Автоматически (NAT)' }}</strong></div>
</div>
</article>
<details class="advanced">
<summary>{{ icon('terminal', 'icon-sm') }}Профиль целиком<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body"><pre class="terminal" style="max-height:360px">{{ profile_json }}</pre></div>
</details>
</aside>
</section>
{% endblock %}
+47 -268
Просмотреть файл
@@ -1,272 +1,51 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ISO — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="v-layout">
{% include "_sidebar.html" %}
{% extends "base.html" %}
{% from "_ui.html" import icon, uploader, empty %}
{% set page_title = 'Образы ОС' %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">ISO</div>
<div class="v-subtitle">Установочные образы только .iso в /var/lib/virtuality/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>
{% block page_header %}{% include "_images_header.html" %}{% endblock %}
{% if error %}
<div class="alert danger">{{ error }}</div>
{% endif %}
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Загрузить установочный ISO</h2>
<span class="pill">только .iso</span>
{% block content %}
<section class="grid grid-main">
<article class="card">
<div class="card-header">
<div><h2>Установочные образы</h2><p>Файлы .iso для установки системы с нуля</p></div>
<form method="post" action="/iso/refresh" class="inline-form" data-toast="Список образов обновлён"><button class="btn btn-sm btn-ghost">{{ icon('refresh-cw', 'icon-sm') }}Обновить</button></form>
</div>
{% if isos %}
<div class="list">
{% for iso in isos %}
<div class="list-item">
<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>
<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">
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить">{{ icon('trash-2', 'icon-sm') }}</button>
</form>
</div>
<form id="iso-upload-form" method="post" action="/iso/upload" enctype="multipart/form-data" class="form-grid">
<label class="upload-box">
<span>Выбери ISO-файл установочного диска</span>
<input id="iso-file-input" type="file" name="iso_file" accept=".iso" required>
</label>
<div id="iso-file-hint" class="muted small-note">Файл ещё не выбран. В этом разделе разрешены только установочные образы .iso.</div>
<div id="iso-file-error" class="alert danger" hidden></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="iso-upload-button" type="submit" class="primary wide">Загрузить ISO</button>
</form>
<p class="muted small-note">Файл попадёт в <b>/var/lib/virtuality/iso</b>. Для готовых системных дисков используй раздел <b>Диски</b>: .img / .raw / .qcow2.</p>
</article>
{% endfor %}
</div>
{% else %}
{{ empty('disc-3', 'Образов пока нет', 'Скачайте установочный образ нужной системы на сайте разработчика и загрузите его сюда.') }}
{% endif %}
</article>
<article class="card">
<div class="card-head">
<h2>Действия</h2>
<span class="pill">pool</span>
</div>
<div class="actions big-actions">
<form method="post" action="/iso/refresh"><button>Обновить ISO pool</button></form>
<a class="link-pill accent" href="/vm/create">Создать VM</a>
</div>
<pre>sudo virsh pool-refresh virtuality-iso
ls -lh /var/lib/virtuality/iso</pre>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>ISO-образы</h2>
<span class="pill">{{ isos|length }} файлов</span>
</div>
<table>
<thead>
<tr><th>Файл</th><th>Размер</th><th>Обновлён</th><th>Действия</th></tr>
</thead>
<tbody>
{% for iso in isos %}
<tr>
<td class="strong">{{ iso.name }}</td>
<td>{{ iso.size }}</td>
<td>{{ iso.updated }}</td>
<td class="actions">
<form method="post" action="/iso/{{ iso.name }}/delete" onsubmit="return confirm('Удалить ISO {{ iso.name }}?');">
<button class="danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">ISO пока нет. Загрузи первый установочный .iso через форму выше.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</div>
<script>
const form = document.getElementById('iso-upload-form');
const fileInput = document.getElementById('iso-file-input');
const button = document.getElementById('iso-upload-button');
const hint = document.getElementById('iso-file-hint');
const errorBox = document.getElementById('iso-file-error');
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');
let activeXhr = null;
let uploadActive = false;
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];
}
function validIsoFile(file) {
return file && file.name && file.name.toLowerCase().endsWith('.iso');
}
function setFileError(message) {
if (!errorBox) return;
errorBox.textContent = message || '';
errorBox.hidden = !message;
}
function setUploadMode(active) {
uploadActive = active;
window.VirtualityUploadActive = active;
document.body.classList.toggle('upload-active', active);
if (active) {
button.disabled = false;
button.type = 'button';
button.textContent = 'Отменить загрузку';
button.classList.remove('primary');
button.classList.add('danger');
fileInput.disabled = true;
} else {
activeXhr = null;
button.type = 'submit';
button.textContent = 'Загрузить ISO';
button.classList.add('primary');
button.classList.remove('danger');
fileInput.disabled = false;
button.disabled = !validIsoFile(fileInput.files[0]);
}
}
window.addEventListener('beforeunload', function (event) {
if (!uploadActive) return;
event.preventDefault();
event.returnValue = 'Идёт загрузка ISO. Если уйти со страницы, загрузка будет прервана.';
});
document.addEventListener('click', function (event) {
if (!uploadActive) return;
const navTarget = event.target.closest('a, form button[type="submit"], form button:not([type])');
if (!navTarget || navTarget === button) return;
event.preventDefault();
event.stopPropagation();
statusLine.textContent = 'Сначала заверши или отмени текущую загрузку.';
}, true);
fileInput.addEventListener('change', function () {
const file = fileInput.files[0];
setFileError('');
if (!file) {
hint.textContent = 'Файл ещё не выбран. В этом разделе разрешены только установочные образы .iso.';
button.disabled = false;
return;
}
if (!validIsoFile(file)) {
const message = 'Неверный тип файла: ' + file.name + '. Здесь можно выбрать только .iso.';
hint.textContent = message;
setFileError(message);
fileInput.value = '';
button.disabled = true;
return;
}
button.disabled = false;
hint.textContent = file.name + ' · ' + bytesText(file.size);
});
button.addEventListener('click', function () {
if (!uploadActive) return;
if (activeXhr) activeXhr.abort();
});
form.addEventListener('submit', function (event) {
event.preventDefault();
if (uploadActive) return;
const file = fileInput.files[0];
setFileError('');
if (!validIsoFile(file)) {
const message = 'Выбери установочный образ .iso.';
hint.textContent = message;
setFileError(message);
return;
}
const xhr = new XMLHttpRequest();
activeXhr = xhr;
const data = new FormData(form);
const started = Date.now();
box.hidden = false;
setUploadMode(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 = 'Файл передан. Сервер сохраняет ISO и обновляет pool…';
};
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 400) {
bar.style.width = '100%';
percent.textContent = '100%';
statusLine.textContent = 'Готово. Обновляем список ISO…';
setUploadMode(false);
window.location.href = '/iso';
} else {
statusLine.textContent = 'Ошибка загрузки: HTTP ' + xhr.status;
setUploadMode(false);
}
};
xhr.onabort = function () {
statusLine.textContent = 'Загрузка отменена пользователем.';
bar.style.width = '0%';
percent.textContent = '0%';
loadedLine.textContent = '0 MB';
speedLine.textContent = 'отменено';
setUploadMode(false);
};
xhr.onerror = function () {
statusLine.textContent = 'Ошибка сети во время загрузки.';
setUploadMode(false);
};
xhr.open('POST', form.action);
xhr.send(data);
});
</script>
<script src="/static/panel.js" defer></script>
</body>
</html>
<aside class="stack">
<article class="card">
<div class="card-header"><h2>Загрузить образ</h2></div>
<div class="card-body">
{{ uploader('/iso/upload', 'iso_file', '.iso', 'Перетащите файл .iso сюда', 'или нажмите, чтобы выбрать на компьютере', '/iso', 'Загрузить') }}
</div>
</article>
<article class="card">
<div class="card-header"><h2>Где взять образ</h2></div>
<div class="list">
{% for name, url, note in [('Ubuntu Server', 'https://ubuntu.com/download/server', 'Популярный Linux для серверов'), ('Debian', 'https://www.debian.org/distrib/', 'Надёжный и лёгкий Linux'), ('Windows Server', 'https://www.microsoft.com/evalcenter', 'Пробная версия от Microsoft')] %}
<a class="list-item" href="{{ url }}" target="_blank" rel="noopener">
<div class="grow"><div class="title">{{ name }}</div><div class="meta">{{ note }}</div></div>{{ icon('external-link', 'icon-sm') }}
</a>
{% endfor %}
</div>
</article>
</aside>
</section>
{% endblock %}
+62 -33
Просмотреть файл
@@ -1,43 +1,72 @@
{% from "_ui.html" import icon, logo, alert %}
<!doctype html>
<html lang="ru">
<html lang="ru" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ app_name }} Login</title>
<meta name="color-scheme" content="light dark">
<title>Вход — Virtuality</title>
<script>
document.documentElement.dataset.theme = localStorage.getItem('virtualityTheme') || 'neon';
(function () {
var mode = 'system';
try { mode = localStorage.getItem('virtualityTheme') || 'system'; } catch (e) {}
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
})();
</script>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/themes.css">
<link rel="stylesheet" href="/static/app.css?v={{ app_version }}">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%235a48f5'/%3E%3Cpath d='M9 10l7 13 7-13' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
</head>
<body class="auth-body">
<main class="auth-shell">
<section class="auth-card">
<div class="brand auth-brand">Virtuality</div>
<p class="muted">Вход в панель управления виртуализацией</p>
{% if not configured %}
<div class="alert danger">Панель ещё не настроена. Повтори установку веб-панели.</div>
{% endif %}
{% if error %}
<div class="alert danger">{{ error }}</div>
{% endif %}
<form method="post" action="/login" class="auth-form">
<label>
<span>Linux-пользователь</span>
<input type="text" name="username" value="{{ auth_user }}" autocomplete="username" required>
</label>
<label>
<span>Пароль</span>
<input type="password" name="password" autocomplete="current-password" required autofocus>
</label>
<button type="submit" class="primary wide">Войти</button>
</form>
<p class="muted small-note">Используется пароль системного пользователя Linux. Пароль не хранится в Virtuality.</p>
<body>
<div class="auth">
<section class="auth-hero">
<div class="logo">
<span class="brand-mark">{{ logo() }}</span>
<span class="brand-name">Virtuality<small>Платформа виртуализации</small></span>
</div>
<div>
<h1>Ваши серверы.<br><span>Под полным контролем.</span></h1>
<p>Создавайте виртуальные машины, открывайте к ним доступ из интернета и следите за состоянием сервера — всё в одном окне, без командной строки.</p>
<div class="auth-features">
<div class="auth-feature"><span class="icon-wrap">{{ icon('zap') }}</span>Новая машина за пару минут</div>
<div class="auth-feature"><span class="icon-wrap">{{ icon('monitor') }}</span>Экран машины прямо в браузере</div>
<div class="auth-feature"><span class="icon-wrap">{{ icon('shield-check') }}</span>Работает на вашем железе, данные у вас</div>
</div>
</div>
<div class="auth-foot">Virtuality {{ app_version }} · KVM · QEMU · libvirt</div>
</section>
</main>
<section class="auth-panel">
<form method="post" action="/login" class="auth-form stack">
<div class="logo auth-mobile-brand">
<span class="brand-mark">{{ logo() }}</span>
<span class="brand-name">Virtuality<small>Платформа виртуализации</small></span>
</div>
<div>
<h2>Вход в панель</h2>
<p>Используйте имя и пароль пользователя этого сервера.</p>
</div>
{% if not configured %}
{{ alert('warning', 'Панель ещё не настроена. Запустите установку web-панели повторно.') }}
{% endif %}
{% if error %}
{{ alert('danger', error) }}
{% endif %}
<div class="field">
<label for="username">Имя пользователя</label>
<input class="input" id="username" type="text" name="username" value="{{ auth_user }}" autocomplete="username" autocapitalize="none" spellcheck="false" required>
</div>
<div class="field">
<label for="password">Пароль</label>
<input class="input" id="password" type="password" name="password" autocomplete="current-password" required autofocus>
</div>
<button type="submit" class="btn btn-primary btn-lg btn-block">Войти{{ icon('arrow-right') }}</button>
<p class="subtle small">{{ icon('lock', 'icon-sm') }} Пароль проверяется системой сервера и нигде не сохраняется.</p>
</form>
</section>
</div>
<script src="/static/panel.js?v={{ app_version }}" defer></script>
</body>
</html>
+31 -89
Просмотреть файл
@@ -1,92 +1,34 @@
<!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="v-layout">
<aside class="v-sidebar">
<div class="v-logo">
<strong>Virtuality</strong>
<span>control panel</span>
</div>
<nav class="v-nav">
<a href="/">Обзор</a>
<a href="/vm/create">Создать VM</a>
<div class="v-nav-group open">
<button class="v-nav-group-title" type="button">Образы</button>
<a href="/iso">ISO</a>
<a href="/disk-images">Диски</a>
</div>
<a href="/network">Сеть</a>
<a href="/operations">Операции</a>
<a class="active" href="/logs">Журналы</a>
<a href="/update">Обновления</a>
<a href="/host">Хост</a>
</nav>
</aside>
{% extends "base.html" %}
{% from "_ui.html" import icon %}
{% set page_title = 'Журналы' %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Журналы</div>
<div class="v-subtitle">systemd, libvirt, update, install и операции Virtuality</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>
<section class="grid stats compact-stats">
{% for key, source in sources.items() %}
<a class="card stat log-source-card {{ 'selected' if key == selected.key else '' }}" href="/logs?source={{ key }}&lines={{ selected.lines }}">
<div class="label">Источник</div>
<div class="value small">{{ source.title }}</div>
</a>
{% endfor %}
</section>
<section class="card">
<div class="card-head">
<div>
<h2>{{ selected.title }}</h2>
<div class="muted small-note">{{ selected.cmd }}</div>
</div>
<div class="actions">
<a class="link-pill" href="/logs?source={{ selected.key }}&lines=120">120</a>
<a class="link-pill" href="/logs?source={{ selected.key }}&lines=300">300</a>
<a class="link-pill" href="/logs?source={{ selected.key }}&lines=1000">1000</a>
<button class="primary" onclick="refreshLog()">Обновить</button>
</div>
</div>
<pre id="log-content" class="op-log">{{ selected.content }}</pre>
</section>
</main>
{% block page_header %}
<div class="page-header">
<div>
<h1>Журналы</h1>
<p class="lead">Записи служб сервера. Пригодятся, если что-то пошло не так, — их можно отправить в поддержку.</p>
</div>
</div>
{% endblock %}
<script>
async function refreshLog() {
const params = new URLSearchParams(window.location.search);
const source = params.get('source') || '{{ selected.key }}';
const lines = params.get('lines') || '{{ selected.lines }}';
const box = document.getElementById('log-content');
try {
const response = await fetch('/api/logs?source=' + encodeURIComponent(source) + '&lines=' + encodeURIComponent(lines), {cache: 'no-store'});
const payload = await response.json();
if (payload.ok && payload.log) {
box.textContent = payload.log.content || 'Лог пуст';
}
} catch (error) {
box.textContent = 'Ошибка обновления лога: ' + error;
}
}
setInterval(refreshLog, 5000);
</script>
<script src="/static/panel.js" defer></script>
</body>
</html>
{% block content %}
<nav class="tabs">
{% for key, source in sources.items() %}
<a href="/logs?source={{ key }}&lines={{ selected.lines }}" class="{{ 'active' if key == selected.key else '' }}">{{ source.title }}</a>
{% endfor %}
</nav>
<article class="card">
<div class="card-header">
<div><h2>{{ selected.title }}</h2><p class="mono">{{ selected.cmd }}</p></div>
<div class="row">
<label class="row small subtle" style="gap:8px;cursor:pointer"><input type="checkbox" data-log-live checked> Обновлять</label>
<div class="segmented">
{% for n in (120, 300, 1000) %}<a href="/logs?source={{ selected.key }}&lines={{ n }}" class="{{ 'active' if selected.lines == n else '' }}">{{ n }}</a>{% endfor %}
</div>
</div>
</div>
<div class="card-body">
<pre class="terminal tall" data-log-source="{{ selected.key }}" data-log-lines="{{ selected.lines }}">{{ selected.content }}</pre>
</div>
</article>
{% endblock %}
+183 -269
Просмотреть файл
@@ -1,271 +1,185 @@
<!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="v-layout">
{% include "_sidebar.html" %}
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, alert, empty %}
{% set page_title = 'Сеть и доступ' %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Сеть</div>
<div class="v-subtitle">NAT Router для VPS, проброс одиночных портов и диапазонов к VM</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 stats">
<article class="card stat">
<div class="label">NAT network</div>
<div class="value small">{{ ctx.nat.name }}</div>
<span class="status {{ 'ok' if ctx.nat.exists else 'warn' }}">{{ 'ready' if ctx.nat.exists else 'not created' }}</span>
</article>
<article class="card stat">
<div class="label">Subnet</div>
<div class="value small">{{ ctx.nat.subnet }}</div>
</article>
<article class="card stat">
<div class="label">External interface</div>
<div class="value small">{{ ctx.external_interface }}</div>
</article>
<article class="card stat">
<div class="label">IPv4 forward</div>
<div class="value small">{{ ctx.ip_forward }}</div>
</article>
</section>
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Virtuality NAT Router</h2>
<span class="pill">{{ ctx.nat.bridge }}</span>
</div>
<div class="meta-grid">
<div><span>Gateway</span><strong>{{ ctx.nat.gateway }}</strong></div>
<div><span>DHCP</span><strong>{{ ctx.nat.dhcp }}</strong></div>
<div><span>Bridge</span><strong>{{ ctx.nat.bridge }}</strong></div>
</div>
<form method="post" action="/network/nat/setup" class="inline-form top-space">
<button class="primary">Создать / починить NAT-сеть</button>
</form>
<pre>{{ ctx.nat.info }}</pre>
</article>
<article class="card">
<div class="card-head">
<h2>DHCP leases</h2>
<span class="pill">virsh net-dhcp-leases</span>
</div>
<pre>{{ ctx.nat.leases }}</pre>
<div class="notice top-space">IP для проброса можно оставить auto или ввести вручную, если DHCP leases пустые, у VM статический IP, bridge-сеть или нет qemu-guest-agent.</div>
</article>
</section>
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Добавить проброс порта</h2>
<span class="pill">DNAT auto/manual IP · ranges</span>
</div>
<form method="post" action="/network/forward/add" class="form-grid">
<label>
<span>Описание</span>
<input type="text" name="note" placeholder="Например: сайт, SSH, SIP, RTP MikoPBX">
</label>
<label>
<span>Внешний порт или диапазон</span>
<input type="text" name="external_port" pattern="[0-9]{1,5}([:-][0-9]{1,5})?" placeholder="80, 443, 2222 или 10000-20000" required>
</label>
<label>
<span>Машина</span>
<select name="vm_name" required>
{% for vm in vms %}
<option value="{{ vm.name }}">{{ vm.name }} — {{ vm.state }}</option>
{% endfor %}
</select>
</label>
<label>
<span>IP VM</span>
<input type="text" name="guest_ip" value="auto" placeholder="auto или 192.168.100.55" required>
<small>Оставь auto для DHCP/qemu-guest-agent или впиши внутренний IP VM вручную.</small>
</label>
<label>
<span>Внутренний порт или диапазон</span>
<input type="text" name="guest_port" list="standard-ports" pattern="[0-9]{1,5}([:-][0-9]{1,5})?" placeholder="Выбери стандартный или введи 10000-20000" required>
<datalist id="standard-ports">
<option value="20" label="FTP data"></option>
<option value="21" label="FTP"></option>
<option value="22" label="SSH"></option>
<option value="25" label="SMTP"></option>
<option value="53" label="DNS"></option>
<option value="80" label="HTTP / web"></option>
<option value="110" label="POP3"></option>
<option value="123" label="NTP"></option>
<option value="143" label="IMAP"></option>
<option value="443" label="HTTPS / SSL web"></option>
<option value="465" label="SMTPS"></option>
<option value="587" label="SMTP submission"></option>
<option value="993" label="IMAPS"></option>
<option value="995" label="POP3S"></option>
<option value="1194" label="OpenVPN"></option>
<option value="1433" label="Microsoft SQL Server"></option>
<option value="1521" label="Oracle Database"></option>
<option value="2049" label="NFS"></option>
<option value="3306" label="MySQL / MariaDB"></option>
<option value="3389" label="RDP / Windows Remote Desktop"></option>
<option value="5060" label="SIP"></option>
<option value="5061" label="SIP TLS"></option>
<option value="5432" label="PostgreSQL"></option>
<option value="5900" label="VNC"></option>
<option value="6379" label="Redis"></option>
<option value="8000" label="HTTP dev"></option>
<option value="8080" label="HTTP alternative"></option>
<option value="8443" label="HTTPS alternative"></option>
<option value="10000" label="Webmin / RTP start"></option>
<option value="10000-20000" label="RTP / VoIP media range"></option>
</datalist>
</label>
<label>
<span>Протокол</span>
<select name="protocol">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</label>
<div class="notice">Можно указать одиночный порт или диапазон. Если авто-IP не определяется, впиши IP VM вручную. Для VoIP/RTP обычно: внешний 10000-20000 → внутренний 10000-20000, протокол udp.</div>
<button class="primary wide">Добавить и применить</button>
</form>
</article>
<article class="card">
<div class="card-head">
<h2>Диагностика публичного доступа</h2>
<span class="pill">VM → Internet</span>
</div>
<form method="post" action="/network/diagnose" class="form-grid">
<label>
<span>Виртуальная машина</span>
<select name="vm_name" required>
{% for vm in vms %}
<option value="{{ vm.name }}">{{ vm.name }} — {{ vm.state }}</option>
{% endfor %}
</select>
</label>
<div class="form-row">
<label>
<span>Внешний порт</span>
<input type="number" name="external_port" min="1" max="65535" value="80" required>
</label>
<label>
<span>Порт внутри VM</span>
<input type="number" name="guest_port" min="1" max="65535" value="80" required>
</label>
</div>
<label>
<span>Протокол</span>
<select name="protocol">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</label>
<button class="primary wide">Проверить доступ</button>
</form>
</article>
</section>
{% if diagnostics %}
<section class="card">
<div class="card-head">
<h2>Результат диагностики</h2>
<span class="pill">{{ diagnostics.vm_name }} · {{ diagnostics.protocol }} {{ diagnostics.external_port }} → {{ diagnostics.guest_port }}</span>
</div>
<div class="notice">{{ diagnostics.verdict }}</div>
<div class="meta-grid top-space">
<div><span>VM IP</span><strong>{{ diagnostics.vm_ip or 'not found' }}</strong></div>
<div><span>External interface</span><strong>{{ diagnostics.external_interface }}</strong></div>
<div><span>Mapping</span><strong>{{ diagnostics.external_port }} → {{ diagnostics.guest_port }}</strong></div>
</div>
<table class="top-space">
<thead><tr><th>Проверка</th><th>Статус</th><th>Детали</th></tr></thead>
<tbody>
{% for check in diagnostics.checks %}
<tr>
<td class="strong">{{ check.name }}</td>
<td><span class="status {{ 'ok' if check.ok else 'err' }}">{{ 'OK' if check.ok else 'FAIL' }}</span></td>
<td class="muted">{{ check.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="grid two top-space">
<article><div class="label">Проверить входящий трафик</div><pre>{{ diagnostics.commands.watch_external }}</pre></article>
<article><div class="label">Проверить трафик к VM</div><pre>{{ diagnostics.commands.watch_internal }}</pre></article>
</div>
</section>
{% endif %}
<section class="card">
<div class="card-head">
<h2>Пробросы портов</h2>
<form method="post" action="/network/apply"><button>Применить правила</button></form>
</div>
<table>
<thead>
<tr><th>Проброс</th><th>VM</th><th>Внутренний адрес</th><th>Протокол</th><th>Описание</th><th>Действия</th></tr>
</thead>
<tbody>
{% for item in ctx.forwards %}
<tr>
<td class="strong">
{{ item.external_port_label|default(item.external_port) }} → {{ item.guest_port_label|default(item.guest_port) }}
{% if item.is_range %}<span class="status warn">range</span>{% endif %}
</td>
<td>{{ item.vm_name }}</td>
<td>{{ item.guest_ip }}</td>
<td><span class="status ok">{{ item.protocol }}</span></td>
<td>{{ item.note }}</td>
<td>
<div class="actions">
<form method="post" action="/network/diagnose">
<input type="hidden" name="vm_name" value="{{ item.vm_name }}">
<input type="hidden" name="external_port" value="{{ item.external_port }}">
<input type="hidden" name="guest_port" value="{{ item.guest_port }}">
<input type="hidden" name="protocol" value="{{ item.protocol }}">
<button>Диагностика</button>
</form>
<form method="post" action="/network/forward/{{ item.id }}/delete"><button class="danger">Удалить</button></form>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">Пробросов пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card">
<div class="card-head">
<h2>nftables preview</h2>
<span class="pill">/etc/virtuality/nftables/virtuality.nft</span>
</div>
<pre>{{ ctx.nft_rules }}</pre>
</section>
</main>
{% block page_header %}
<div class="page-header">
<div>
<h1>Сеть и доступ</h1>
<p class="lead">Откройте доступ к машинам из интернета: сайт, SSH, удалённый рабочий стол. Всё остальное остаётся закрытым.</p>
</div>
<script src="/static/panel.js" defer></script>
</body>
</html>
</div>
{% endblock %}
{% block content %}
{% if diagnostics %}
{% set failed = diagnostics.checks|rejectattr('ok')|list %}
<article class="card" style="margin-bottom:20px">
<div class="card-header">
<div><h2>Проверка доступа: {{ diagnostics.vm_name }}, порт {{ diagnostics.external_port }}</h2><p>{{ diagnostics.protocol|upper }} {{ diagnostics.external_port }} → {{ diagnostics.vm_ip or 'IP не найден' }}:{{ diagnostics.guest_port }}</p></div>
{% if failed %}{{ badge('Есть проблемы: ' ~ failed|length, 'warning') }}{% else %}{{ badge('Всё в порядке', 'success') }}{% endif %}
</div>
<div class="card-body stack-sm">
{{ alert('warning' if failed else 'success', diagnostics.verdict) }}
</div>
<div class="checklist">
{% for check in diagnostics.checks %}
<div class="check-item {{ 'success' if check.ok else 'danger' }}">{{ icon('circle-check' if check.ok else 'circle-x') }}<div style="min-width:0"><strong>{{ check.name }}</strong><span class="truncate" style="display:block">{{ check.detail|truncate(160) }}</span></div></div>
{% endfor %}
</div>
<div class="card-footer" style="justify-content:flex-start;display:grid;gap:8px">
<span class="subtle small">Проверить входящий трафик на сервере:</span>
<div class="code-line">{{ diagnostics.commands.watch_external }}<button type="button" class="btn btn-ghost btn-icon btn-sm" data-copy="{{ diagnostics.commands.watch_external }}" style="margin-left:auto">{{ icon('copy', 'icon-sm') }}</button></div>
</div>
</article>
{% endif %}
<section class="grid grid-main">
<article class="card">
<div class="card-header">
<div><h2>Доступ из интернета</h2><p>Порты сервера, которые ведут внутрь машин</p></div>
{% if ctx.forwards %}<form method="post" action="/network/apply" class="inline-form" data-toast="Правила применены заново"><button class="btn btn-sm btn-ghost">{{ icon('refresh-cw', 'icon-sm') }}Применить заново</button></form>{% endif %}
</div>
{% if ctx.forwards %}
<div class="list">
{% for item in ctx.forwards %}
{% set service = item.note or (item.guest_port|port_service) or ('Порт ' ~ item.guest_port_label) %}
<div class="list-item">
<span class="list-icon success">{{ icon('globe') }}</span>
<div class="grow">
<div class="title">{{ service }}</div>
<div class="meta">Сервер : <b class="mono">{{ item.external_port_label }}</b> → {{ item.vm_name }} : <b class="mono">{{ item.guest_port_label }}</b> · {{ item.protocol|upper }}{% if item.is_range %} · диапазон{% endif %}</div>
</div>
<form method="post" action="/network/diagnose" class="inline-form">
<input type="hidden" name="vm_name" value="{{ item.vm_name }}"><input type="hidden" name="external_port" value="{{ item.external_port }}"><input type="hidden" name="guest_port" value="{{ item.guest_port }}"><input type="hidden" name="protocol" value="{{ item.protocol }}">
<button class="btn btn-sm hide-sm">{{ icon('activity', 'icon-sm') }}Проверить</button>
</form>
<form method="post" action="/network/forward/{{ item.id }}/delete" class="inline-form" data-confirm="Доступ к «{{ service }}» из интернета будет закрыт." 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>
</form>
</div>
{% endfor %}
</div>
{% else %}
{{ empty('shield-check', 'Всё закрыто', 'Машины выходят в интернет, но снаружи к ним подключиться нельзя. Откройте нужный порт в форме справа.') }}
{% endif %}
</article>
<article class="card">
<div class="card-header"><div><h2>Открыть доступ</h2><p>Сделайте сервис машины доступным снаружи</p></div></div>
{% if vms %}
<form method="post" action="/network/forward/add" data-toast="Доступ открыт">
<div class="card-body form-grid">
<div class="field">
<label for="fw-vm">Машина</label>
<select class="select" id="fw-vm" name="vm_name" required>
{% for vm in vms %}<option value="{{ vm.name }}">{{ vm.name }}{% if vm.ip and vm.ip != '—' %} · {{ vm.ip }}{% endif %}</option>{% endfor %}
</select>
</div>
<div class="field">
<label for="fw-preset">Что открыть</label>
<select class="select" id="fw-preset" data-port-preset>
<option value="">Выберите сервис…</option>
{% for p in port_presets %}<option value="{{ p.port }}" data-protocol="{{ p.protocol }}">{{ p.label }}</option>{% endfor %}
<option value="custom">Другой порт</option>
</select>
</div>
<div class="form-row">
<div class="field"><label for="fw-guest">Порт в машине</label><input class="input" id="fw-guest" type="text" name="guest_port" pattern="[0-9]{1,5}([:\-][0-9]{1,5})?" placeholder="22" required></div>
<div class="field"><label for="fw-ext">Порт на сервере</label><input class="input" id="fw-ext" type="text" name="external_port" pattern="[0-9]{1,5}([:\-][0-9]{1,5})?" placeholder="2222" required></div>
</div>
<div class="field">
<label>Протокол</label>
<div class="segmented" role="radiogroup">
<label style="display:contents"><input type="radio" name="protocol" value="tcp" checked hidden><button type="button" class="active" data-proto="tcp">TCP</button></label>
<label style="display:contents"><input type="radio" name="protocol" value="udp" hidden><button type="button" data-proto="udp">UDP</button></label>
</div>
</div>
<details class="advanced">
<summary>{{ icon('wrench', 'icon-sm') }}Дополнительно<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body form-grid">
<div class="field"><label for="fw-ip">IP-адрес машины</label><input class="input" id="fw-ip" type="text" name="guest_ip" value="auto"><span class="hint">«auto» — определить автоматически</span></div>
<div class="field"><label for="fw-note">Название</label><input class="input" id="fw-note" type="text" name="note" maxlength="120" placeholder="например, Сайт компании"></div>
</div>
</details>
</div>
<div class="card-footer"><button type="submit" class="btn btn-primary">{{ icon('globe', 'icon-sm') }}Открыть доступ</button></div>
</form>
{% else %}
<div class="card-body subtle">Сначала <a href="/vm/create">создайте машину</a>.</div>
{% endif %}
</article>
</section>
<section class="grid grid-2 mt-3">
<article class="card">
<div class="card-header">
<div><h2>Внутренняя сеть машин</h2><p>Через неё машины выходят в интернет</p></div>
{% if ctx.nat.exists %}{{ badge('Работает', 'success') }}{% else %}{{ badge('Не создана', 'warning') }}{% endif %}
</div>
<div class="card-body stack-sm">
<div class="kv">
<div><span>Адреса машин</span><strong class="mono">{{ ctx.nat.subnet }}</strong></div>
<div><span>Шлюз</span><strong class="mono">{{ ctx.nat.gateway }}</strong></div>
<div><span>Выход в интернет</span><strong class="mono">{{ ctx.external_interface }}</strong></div>
<div><span>Маршрутизация</span><strong>{{ 'Включена' if ctx.ip_forward == 'enabled' else 'Выключена' }}</strong></div>
</div>
<form method="post" action="/network/nat/setup" data-toast="Сеть проверена и настроена"><button class="btn btn-sm">{{ icon('wrench', 'icon-sm') }}{{ 'Проверить и починить' if ctx.nat.exists else 'Создать сеть' }}</button></form>
</div>
</article>
<article class="card">
<div class="card-header"><div><h2>Адреса машин</h2><p>Если адрес не определился сам, укажите его вручную</p></div></div>
<div class="list">
{% for vm in vms %}
<form method="post" action="/network/vm-ip/save" class="list-item" data-toast="Адрес сохранён">
<span class="list-icon">{{ icon('monitor') }}</span>
<div class="grow"><div class="title">{{ vm.name }}</div><div class="meta">{{ vm.ip if vm.ip and vm.ip != '—' else 'адрес не определён' }}{% if vm.manual_ip %} · задан вручную{% endif %}</div></div>
<input type="hidden" name="vm_name" value="{{ vm.name }}">
<input class="input" style="width:150px;height:32px" type="text" name="manual_ip" value="{{ vm.manual_ip }}" placeholder="авто" aria-label="IP вручную">
<button class="btn btn-sm">Сохранить</button>
</form>
{% else %}
<div class="list-item subtle">Машин пока нет</div>
{% endfor %}
</div>
</article>
</section>
<details class="advanced mt-3">
<summary>{{ icon('terminal', 'icon-sm') }}Для специалистов: DHCP и правила nftables<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body grid grid-2">
<div><div class="terminal-head"><i></i><i></i><i></i><span>virsh net-dhcp-leases {{ ctx.nat.name }}</span></div><pre class="terminal">{{ ctx.nat.leases or '—' }}</pre></div>
<div><div class="terminal-head"><i></i><i></i><i></i><span>/etc/virtuality/nftables/virtuality.nft</span></div><pre class="terminal">{{ ctx.nft_rules }}</pre></div>
</div>
</details>
{% endblock %}
{% block scripts %}
<script>
(function () {
var preset = document.querySelector('[data-port-preset]');
if (!preset) return;
var guest = document.getElementById('fw-guest'), ext = document.getElementById('fw-ext'), note = document.getElementById('fw-note');
function setProto(value) {
document.querySelectorAll('[data-proto]').forEach(function (btn) {
var on = btn.dataset.proto === value;
btn.classList.toggle('active', on);
btn.previousElementSibling.checked = on;
});
}
document.querySelectorAll('[data-proto]').forEach(function (btn) { btn.addEventListener('click', function () { setProto(btn.dataset.proto); }); });
preset.addEventListener('change', function () {
var opt = preset.options[preset.selectedIndex];
if (!opt.value || opt.value === 'custom') { if (opt.value === 'custom') guest.focus(); return; }
guest.value = opt.value;
var port = parseInt(opt.value, 10);
ext.value = opt.value.indexOf('-') > -1 ? opt.value : (port === 22 ? '2222' : port === 3389 ? '33890' : opt.value);
setProto(opt.dataset.protocol || 'tcp');
if (note && !note.value) note.value = opt.text.split(' — ')[0];
});
guest.addEventListener('input', function () { if (!ext.dataset.touched) ext.value = guest.value; });
ext.addEventListener('input', function () { ext.dataset.touched = '1'; });
})();
</script>
{% endblock %}
+61 -168
Просмотреть файл
@@ -1,170 +1,63 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ operation.title }} — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="shell">
<header class="topbar">
<div>
<div class="brand">{{ operation.title }}</div>
<div class="muted">Фоновая операция Virtuality</div>
</div>
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/operations">Операции</a>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
{% extends "base.html" %}
{% from "_ui.html" import icon, op_badge %}
{% set page_title = operation.title %}
{% set done = operation.status in ('success', 'error') %}
<section class="grid two">
<article class="card" id="operation-card" data-operation-id="{{ operation.id }}" data-initial-progress="{{ operation.progress }}">
<div class="card-head">
<h2>Состояние</h2>
<span id="operation-status" class="status {{ operation_css(operation.status) }}">{{ operation.status }}</span>
</div>
<div class="operation-summary">
<div>
<div class="label">Прогресс</div>
<div class="progress big"><span id="operation-progress-bar" style="width: {{ operation.progress }}%"></span></div>
<div id="operation-progress-text" class="muted small-note">{{ operation.progress }}%</div>
</div>
<div>
<div class="label">Сообщение</div>
<div id="operation-message" class="value small">{{ operation.message }}</div>
</div>
</div>
<div class="meta-grid">
<div><span>VM</span><strong>{{ operation.vm_name }}</strong></div>
<div><span>RAM</span><strong>{{ operation.memory }} MB</strong></div>
<div><span>CPU</span><strong>{{ operation.vcpus }}</strong></div>
<div><span>Disk</span><strong>{{ operation.disk_size }} GB</strong></div>
<div><span>Bridge</span><strong>{{ operation.bridge }}</strong></div>
<div><span>Created</span><strong>{{ operation.created_at }}</strong></div>
</div>
</article>
<article class="card">
<div class="card-head">
<h2>Команда</h2>
<span class="pill">virt-install</span>
</div>
<pre class="command-box">{{ operation.cmd }}</pre>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Журнал virt-install</h2>
<span class="pill live-dot">live</span>
</div>
<pre id="operation-log" class="op-log">{{ operation.log_tail }}</pre>
</section>
<script>
const card = document.getElementById('operation-card');
const operationId = card.dataset.operationId;
const statusEl = document.getElementById('operation-status');
const progressBar = document.getElementById('operation-progress-bar');
const progressText = document.getElementById('operation-progress-text');
const messageEl = document.getElementById('operation-message');
const logEl = document.getElementById('operation-log');
let serverProgress = Number(card.dataset.initialProgress || 0);
let visualProgress = serverProgress;
let lastServerProgress = serverProgress;
let currentStatus = statusEl.textContent.trim() || 'queued';
let lastProgressAt = Date.now();
function statusClass(status) {
if (status === 'success') return 'status ok';
if (status === 'error') return 'status err';
return 'status warn';
}
function setProgress(value) {
const safeValue = Math.max(0, Math.min(100, Math.round(value)));
progressBar.style.width = `${safeValue}%`;
progressText.textContent = `${safeValue}%`;
}
function interpolateProgress() {
if (currentStatus === 'success') {
visualProgress = 100;
setProgress(100);
clearInterval(window.virtualitySmoothProgressTimer);
return;
}
if (currentStatus === 'error') {
visualProgress = Math.max(visualProgress, serverProgress || 100);
setProgress(visualProgress);
clearInterval(window.virtualitySmoothProgressTimer);
return;
}
if (currentStatus !== 'queued' && currentStatus !== 'running') return;
const ceiling = currentStatus === 'queued' ? 12 : 95;
const ageSeconds = Math.max(1, Math.floor((Date.now() - lastProgressAt) / 1000));
const baseStep = currentStatus === 'queued' ? 0.35 : 0.75;
const slowDown = visualProgress > 80 ? 0.28 : visualProgress > 60 ? 0.45 : 1;
const step = Math.min(2.5, baseStep * slowDown * ageSeconds);
if (visualProgress < ceiling) {
visualProgress = Math.min(ceiling, Math.max(visualProgress, serverProgress) + step);
setProgress(visualProgress);
}
}
async function refreshOperation() {
try {
const response = await fetch(`/api/operations/${operationId}`, {cache: 'no-store'});
if (!response.ok) return;
const payload = await response.json();
if (!payload.ok) return;
const op = payload.operation;
currentStatus = op.status || currentStatus;
serverProgress = Number(op.progress || 0);
statusEl.textContent = currentStatus;
statusEl.className = statusClass(currentStatus);
if (serverProgress !== lastServerProgress || currentStatus === 'success' || currentStatus === 'error') {
lastProgressAt = Date.now();
lastServerProgress = serverProgress;
}
if (currentStatus === 'success') {
visualProgress = 100;
} else if (currentStatus === 'error') {
visualProgress = Math.max(visualProgress, serverProgress);
} else {
visualProgress = Math.max(visualProgress, serverProgress);
}
setProgress(visualProgress);
messageEl.textContent = op.message || '';
logEl.textContent = op.log_tail || '';
logEl.scrollTop = logEl.scrollHeight;
if (currentStatus === 'success' || currentStatus === 'error') {
clearInterval(window.virtualityOperationTimer);
}
} catch (error) {
messageEl.textContent = 'Ожидаем ответ операции...';
}
}
window.virtualityOperationTimer = setInterval(refreshOperation, 1500);
window.virtualitySmoothProgressTimer = setInterval(interpolateProgress, 1000);
refreshOperation();
interpolateProgress();
</script>
{% block page_header %}
<div class="page-header">
<div>
<div class="eyebrow"><a href="/operations">{{ icon('arrow-left', 'icon-sm') }} Все задачи</a></div>
<h1>{{ operation.title }}</h1>
<p class="lead">Начата {{ operation.created_at }}</p>
</div>
</body>
</html>
</div>
{% endblock %}
{% block content %}
<section class="grid grid-main">
<div class="stack">
<article class="card" data-operation-id="{{ operation.id }}" data-progress="{{ operation.progress|default(0) }}" data-status="{{ operation.status }}">
<div class="card-body stack-sm">
<div class="row-between">
<span data-op="status">{{ op_badge(operation.status, 'lg') }}</span>
<strong class="stat-value" style="margin:0;font-size:22px" data-op="pct">{{ operation.progress|default(0) }}%</strong>
</div>
<div class="progress lg {{ 'success' if operation.status == 'success' else 'danger' if operation.status == 'error' else '' }}" data-op="progress" style="height:10px"><span data-op="bar" style="width:{{ operation.progress|default(0) }}%"></span></div>
<p class="muted" data-op="message">{{ operation.message }}</p>
{% if operation.vm_name %}
<div class="row" data-op-done {% if operation.status != 'success' %}hidden{% endif %}>
<a class="btn btn-primary" href="/vm/{{ operation.vm_name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран машины</a>
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
</div>
{% endif %}
</div>
</article>
<div>
<div class="terminal-head"><i></i><i></i><i></i><span style="margin-left:6px">Журнал выполнения</span></div>
<pre class="terminal tall" data-op="log">{{ operation.log_tail or 'Ожидаем вывод…' }}</pre>
</div>
</div>
<aside class="stack">
<article class="card">
<div class="card-header"><h2>Параметры</h2></div>
<div class="card-body" style="padding-top:6px;padding-bottom:6px">
{% if operation.vm_name %}<div class="summary-row"><span>Машина</span><strong>{{ operation.vm_name }}</strong></div>{% endif %}
{% if operation.vcpus %}<div class="summary-row"><span>Процессор</span><strong>{{ operation.vcpus }} {{ plural(operation.vcpus|int, 'ядро', 'ядра', 'ядер') }}</strong></div>{% endif %}
{% if operation.memory %}<div class="summary-row"><span>Память</span><strong>{{ operation.memory|format_mb }}</strong></div>{% endif %}
{% if operation.disk_size %}<div class="summary-row"><span>Диск</span><strong>{{ operation.disk_size }} ГБ</strong></div>{% endif %}
{% if operation.network_mode %}<div class="summary-row"><span>Сеть</span><strong>{{ 'Локальная сеть' if operation.network_mode == 'bridge' else 'Автоматически (NAT)' }}</strong></div>{% endif %}
{% if operation.source_path %}<div class="summary-row"><span>Файл</span><strong>{{ operation.source_path.split('/')[-1] }}</strong></div>{% endif %}
<div class="summary-row"><span>Запустил</span><strong>{{ operation.created_by|default('—') }}</strong></div>
</div>
</article>
{% if operation.cmd %}
<details class="advanced">
<summary>{{ icon('terminal', 'icon-sm') }}Команда<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body"><pre class="terminal" style="max-height:260px">{{ operation.cmd }}</pre></div>
</details>
{% endif %}
</aside>
</section>
{% endblock %}
+36 -54
Просмотреть файл
@@ -1,56 +1,38 @@
<!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="v-layout">
{% include "_sidebar.html" %}
{% extends "base.html" %}
{% from "_ui.html" import icon, op_badge, empty %}
{% set page_title = 'Задачи' %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Операции</div>
<div class="v-subtitle">Журнал фоновых действий Virtuality</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>
<section class="card">
<div class="card-head">
<h2>Последние операции</h2>
<span class="pill">/var/log/virtuality/operations</span>
</div>
<table>
<thead>
<tr><th>Операция</th><th>Статус</th><th>Создана</th><th>Действие</th></tr>
</thead>
<tbody>
{% for operation in operations %}
<tr>
<td>
<div class="strong">{{ operation.title }}</div>
<div class="muted small-note">{{ operation.message }}</div>
</td>
<td><span class="status {{ operation_css(operation.status) }}">{{ operation.status }}</span></td>
<td>{{ operation.created_at }}</td>
<td><a class="link-pill" href="/operations/{{ operation.id }}">Открыть</a></td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">Журнал операций пуст</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
{% block page_header %}
<div class="page-header">
<div>
<h1>Задачи</h1>
<p class="lead">Долгие операции выполняются в фоне: создание машин, распаковка и конвертация образов. Можно закрыть страницу — работа продолжится.</p>
</div>
<script src="/static/panel.js" defer></script>
</body>
</html>
</div>
{% endblock %}
{% block content %}
<article class="card">
{% if operations %}
<div class="table-wrap">
<table class="table">
<thead><tr><th>Задача</th><th>Статус</th><th class="hide-sm">Прогресс</th><th class="hide-sm">Начата</th><th></th></tr></thead>
<tbody>
{% for op in operations %}
{% set os = op.status|operation_state %}
<tr>
<td><a class="strong" href="/operations/{{ op.id }}" style="color:inherit">{{ op.title }}</a><div class="subtle small truncate" style="max-width:420px">{{ op.message }}</div></td>
<td>{{ op_badge(op.status) }}</td>
<td class="hide-sm" style="width:180px"><div class="progress {{ 'success' if op.status == 'success' else 'danger' if op.status == 'error' else '' }}"><span style="width:{{ op.progress|default(0) }}%"></span></div></td>
<td class="hide-sm nowrap subtle">{{ op.created_at }}</td>
<td><div class="actions"><a class="btn btn-sm btn-ghost" href="/operations/{{ op.id }}">Подробнее{{ icon('chevron-right', 'icon-sm') }}</a></div></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty('list-checks', 'Задач пока нет', 'Когда вы создадите машину или загрузите образ, здесь появится ход выполнения.', '/vm/create', 'Создать машину') }}
{% endif %}
</article>
{% endblock %}
+68 -188
Просмотреть файл
@@ -1,199 +1,79 @@
<!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="v-layout">
<aside class="v-sidebar">
<div class="v-logo">
<strong>Virtuality</strong>
<span>control panel</span>
</div>
<nav class="v-nav">
<a href="/">Обзор</a>
<a href="/vm/create">Создать VM</a>
<div class="v-nav-group open">
<button class="v-nav-group-title" type="button">Образы</button>
<a href="/iso">ISO</a>
<a href="/disk-images">Диски</a>
</div>
<a href="/network">Сеть</a>
<a href="/operations">Операции</a>
<a href="/logs">Журналы</a>
<a class="active" href="/update">Обновления</a>
<a href="/host">Хост</a>
</nav>
</aside>
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, alert %}
{% set page_title = 'Обновления' %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Обновления</div>
<div class="v-subtitle">GitHub update center: changelog, проверка и установка</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>
{% block page_header %}
<div class="page-header">
<div>
<h1>Обновления</h1>
<p class="lead">Новые возможности и исправления Virtuality. Машины продолжают работать во время обновления.</p>
</div>
</div>
{% endblock %}
{% if error %}
<div class="alert danger">{{ error }}</div>
{% endif %}
<section class="grid stats">
<article class="card stat">
<div class="label">Текущая версия</div>
<div class="value small">{{ info.current_version }}</div>
</article>
<article class="card stat">
<div class="label">Новая версия</div>
<div class="value small">{{ info.latest_version }}</div>
</article>
<article class="card stat">
<div class="label">Статус</div>
<div class="value small">{{ 'Есть обновление' if info.has_update else 'Актуально' }}</div>
<span class="status {{ 'warn' if info.has_update else 'ok' }}">{{ 'update' if info.has_update else 'ok' }}</span>
</article>
<article class="card stat">
<div class="label">Последняя проверка</div>
<div class="value small" id="last-check-value">{{ info.checked_at }}</div>
</article>
</section>
<section class="grid two update-main-grid">
<article class="card">
<div class="card-head">
<h2>Проверка GitHub</h2>
<span class="pill">{{ info.remote }}/{{ info.branch }}</span>
</div>
<div class="meta-grid">
<div><span>Source</span><strong>{{ info.source_dir }}</strong></div>
<div><span>Local commit</span><strong>{{ info.current_commit[:12] if info.current_commit else 'unknown' }}</strong></div>
<div><span>Remote commit</span><strong>{{ info.latest_commit[:12] if info.latest_commit else 'unknown' }}</strong></div>
</div>
{% if not info.fetch_ok %}
<div class="alert danger top-space">GitHub fetch вернул ошибку: {{ info.fetch_error }}</div>
{% endif %}
<div class="actions top-space update-actions">
{% block content %}
<section class="grid grid-main">
<div class="stack">
<article class="card {{ 'hero-card' if info.has_update else '' }}">
<div class="card-body row-between" style="align-items:center">
<div class="row" style="gap:16px;align-items:center">
<span class="empty-art" style="margin:0;width:56px;height:56px;{{ 'background:rgba(255,255,255,.14);color:#fff' if info.has_update else '' }}">{{ icon('sparkles' if info.has_update else 'shield-check', 'icon-xl') }}</span>
<div>
{% if info.has_update %}
<form method="post" action="/update/apply" id="update-apply-form" onsubmit="return confirm('Запустить обновление Virtuality из GitHub? Панель перезапустится.');">
<button class="primary" id="update-apply-button" type="submit">Установить обновления</button>
</form>
<h2>Доступна версия {{ info.latest_version }}</h2>
<p>У вас установлена {{ info.current_version }}. {% if info.missing_versions %}Пропущено обновлений: {{ info.missing_versions|length }}.{% endif %}</p>
{% else %}
<form method="post" action="/update/check" id="update-check-form">
<button class="primary" id="update-main-button" type="submit">Проверить обновления</button>
</form>
<h2>Установлена последняя версия</h2>
<p class="muted mt-1">Virtuality {{ info.current_version }} · проверено {{ info.checked_at }}</p>
{% endif %}
</div>
</article>
</div>
<div class="row">
{% if info.has_update %}
<form method="post" action="/update/apply" data-confirm="Панель перезапустится и будет недоступна около минуты. Виртуальные машины продолжат работать." data-confirm-title="Установить обновление?" data-confirm-button="Обновить" data-confirm-tone="brand" data-toast="Обновление запущено">
<button class="btn btn-lg" style="background:#fff;color:#14112e;border:0">{{ icon('download') }}Обновить</button>
</form>
{% else %}
<form method="post" action="/update/check" data-busy><button class="btn" data-busy="Проверяем…">{{ icon('refresh-cw', 'icon-sm') }}Проверить сейчас</button></form>
{% endif %}
</div>
</div>
</article>
<article class="card missed-versions-card">
<div class="card-head">
<h2>Пропущенные версии и функции</h2>
<span class="pill">{{ info.missing_versions|length }} версий</span>
</div>
<div class="missed-versions-list">
{% for release in info.missing_versions %}
<div class="release-note">
<div class="card-head compact">
<h2>{{ release.version }} · {{ release.title }}</h2>
<span class="pill">{{ release.date }}</span>
</div>
<p class="muted">{{ release.summary }}</p>
<ul>
{% for feature in release.features %}
<li>{{ feature }}</li>
{% endfor %}
</ul>
</div>
{% else %}
<div class="muted">Нет пропущенных версий. Система на свежем коде.</div>
{% endfor %}
</div>
</article>
</section>
<div class="alert info" data-update-running hidden>{{ icon('refresh-cw') }}<div><strong>Идёт обновление</strong><span data-update-state></span></div></div>
{% if not info.fetch_ok %}{{ alert('warning', 'Не удалось связаться с GitHub: ' ~ (info.fetch_error or 'нет ответа') ~ '. Проверьте доступ сервера в интернет.', 'Проверка не удалась') }}{% endif %}
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Новые коммиты</h2>
<span class="pill">{{ info.commits|length }}</span>
<article class="card">
<div class="card-header"><div><h2>Что нового</h2><p>{{ 'Изменения, которые вы получите' if info.missing_versions else 'Последние изменения' }}</p></div></div>
{% set releases = info.missing_versions or ([current_release] if current_release else []) %}
{% if releases %}
{% for release in releases|reverse %}
<div class="release">
<div class="row-between"><div class="row"><span class="badge brand">{{ release.version }}</span><h3>{{ release.title }}</h3></div><span class="subtle small">{{ release.date }}</span></div>
<p class="muted mt-1">{{ release.summary }}</p>
<ul>{% for feature in release.features %}<li>{{ feature }}</li>{% endfor %}</ul>
</div>
<div class="services">
{% for commit in info.commits %}
<div class="service-row">
<span><strong>{{ commit.short }}</strong><small>{{ commit.subject }} · {{ commit.date }}</small></span>
</div>
{% else %}
<div class="muted">Новых коммитов нет.</div>
{% endfor %}
</div>
</article>
<article class="card">
<div class="card-head">
<h2>Лог обновления</h2>
<span id="update-log-state" class="pill">live</span>
</div>
<pre id="update-log">{{ info.log_tail or 'Лог пока пуст.' }}</pre>
</article>
</section>
</main>
{% endfor %}
{% else %}
<div class="card-body subtle">Новых изменений нет — всё уже установлено.</div>
{% endif %}
</article>
</div>
<script>
const logBox = document.getElementById('update-log');
const logState = document.getElementById('update-log-state');
const checkForm = document.getElementById('update-check-form');
const applyForm = document.getElementById('update-apply-form');
const mainButton = document.getElementById('update-main-button');
const applyButton = document.getElementById('update-apply-button');
function stageText(state) {
const status = state.status || 'idle';
const message = state.message || '';
if (status === 'running') return message || 'Установка выполняется';
if (status === 'success') return 'Обновление установлено';
if (status === 'error') return 'Ошибка обновления';
return message || 'Готово';
}
async function refreshUpdateStatus() {
try {
const response = await fetch('/update/status', {cache: 'no-store'});
const payload = await response.json();
if (!payload.ok) return;
const state = payload.state || {};
logBox.textContent = payload.log_tail || 'Лог пока пуст.';
logState.textContent = 'live · ' + (payload.checked_at || 'now');
if (applyButton && state.status === 'running') applyButton.textContent = stageText(state);
} catch (error) {
logState.textContent = 'live error';
}
}
if (checkForm) {
checkForm.addEventListener('submit', () => {
if (mainButton) mainButton.textContent = 'Проверяем GitHub...';
});
}
if (applyForm) {
applyForm.addEventListener('submit', () => {
if (applyButton) applyButton.textContent = 'Запускаем обновление...';
setTimeout(refreshUpdateStatus, 1200);
setTimeout(refreshUpdateStatus, 2600);
});
}
refreshUpdateStatus();
setInterval(refreshUpdateStatus, 2000);
</script>
<script src="/static/panel.js" defer></script>
</body>
</html>
<aside class="stack">
<article class="card">
<div class="card-header"><h2>Версия</h2></div>
<div class="card-body" style="padding-top:6px;padding-bottom:6px">
<div class="summary-row"><span>Установлена</span><strong>{{ info.current_version }}</strong></div>
{% if info.has_update %}<div class="summary-row"><span>Доступна</span><strong>{{ info.latest_version }}</strong></div>{% endif %}
<div class="summary-row"><span>Канал</span><strong>{{ info.branch }}</strong></div>
<div class="summary-row"><span>Сборка</span><strong class="mono">{{ info.current_commit[:8] if info.current_commit else '—' }}</strong></div>
</div>
</article>
<details class="advanced" {% if info.commits %}open{% endif %}>
<summary>{{ icon('terminal', 'icon-sm') }}Журнал обновления<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body"><pre class="terminal" style="max-height:320px" data-update-log>{{ info.log_tail or 'Журнал пока пуст.' }}</pre></div>
</details>
</aside>
</section>
{% endblock %}
+156 -188
Просмотреть файл
@@ -1,203 +1,171 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Создать VM — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
{% set disk_images_available = disk_images is defined and disk_images %}
{% set disk_feature_available = disk_images is defined %}
<div class="v-layout">
{% include "_sidebar.html" %}
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, alert %}
{% set page_title = 'Новая машина' %}
{% set source = form.source_type|default('iso') %}
{% set preset_match = namespace(id='custom') %}
{% for p in vm_presets %}{% if p.vcpus|string == form.vcpus|string and p.memory|string == form.memory|string and p.disk_size|string == form.disk_size|string %}{% set preset_match.id = p.id %}{% endif %}{% endfor %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Создать VM</div>
<div class="v-subtitle">Новая виртуальная машина через libvirt / virt-install</div>
{% block page_header %}
<div class="page-header">
<div>
<div class="eyebrow"><a href="/">{{ icon('arrow-left', 'icon-sm') }} Все машины</a></div>
<h1>Новая виртуальная машина</h1>
<p class="lead">Заполните три шага — всё остальное Virtuality настроит сам.</p>
</div>
</div>
{% endblock %}
{% block content %}
<form method="post" action="/vm/create" class="grid grid-main" data-wizard data-has-isos="{{ '1' if isos else '0' }}" data-has-disks="{{ '1' if disk_images else '0' }}">
<div class="stack">
<article class="card step-card">
<div class="step-head"><span class="step-num">1</span><div><h2>Название и система</h2><p>Как назовём машину и что на неё установить</p></div></div>
<div class="step-body form-grid">
<div class="field">
<label for="name">Название машины</label>
<input class="input" id="name" type="text" name="name" value="{{ form.name or '' }}" placeholder="например, web-server" pattern="[a-zA-Z0-9][a-zA-Z0-9_.\-]{1,62}" autocomplete="off" required>
<span class="hint">Латинские буквы, цифры, точка, дефис. От 2 до 63 символов.</span>
</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 class="options" style="grid-template-columns:repeat(auto-fit,minmax(220px,1fr))">
<label class="option">
<input type="radio" name="source_type" value="iso" {% if source != 'disk_image' %}checked{% endif %}>
<span class="option-body">
<span class="option-top"><span class="option-icon">{{ icon('disc-3') }}</span><span class="option-check"></span></span>
<span class="option-title">Установить с диска</span>
<span class="option-text">Установочный образ .iso — как установка на обычный компьютер</span>
</span>
</label>
<label class="option">
<input type="radio" name="source_type" value="disk_image" {% if source == 'disk_image' %}checked{% endif %}>
<span class="option-body">
<span class="option-top"><span class="option-icon">{{ icon('hard-drive') }}</span><span class="option-check"></span></span>
<span class="option-title">Готовая система</span>
<span class="option-text">Образ уже установленной системы: .qcow2, .img, .raw</span>
</span>
</label>
</div>
</header>
{% if error %}
<div class="alert danger">{{ error }}</div>
{% endif %}
<div class="field" data-show-when="source_type=iso">
<label for="iso_path">Установочный образ</label>
{% if isos %}
<select class="select" id="iso_path" name="iso_path">
{% for iso in isos %}<option value="{{ iso.path }}" data-label="{{ iso.name }}" {% if form.iso_path == iso.path %}selected{% endif %}>{{ iso.name }} · {{ iso.size }}</option>{% endfor %}
</select>
<span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
{% endif %}
</div>
<div class="field" data-show-when="source_type=disk_image">
<label for="disk_image_path">Образ системы</label>
{% if disk_images %}
<select class="select" id="disk_image_path" name="disk_image_path">
{% for image in disk_images %}<option value="{{ image.path }}" data-label="{{ image.name }}" {% if form.disk_image_path == image.path %}selected{% endif %}>{{ image.name }} · {{ image.size }}</option>{% endfor %}
</select>
<span class="hint">Образ будет скопирован — оригинал останется для других машин.</span>
{% endif %}
</div>
<div class="alert warning" data-missing-source="iso" hidden>{{ icon('triangle-alert') }}<div><strong>Нет установочных образов</strong>Загрузите .iso с системой, например Ubuntu или Debian.</div><a class="btn btn-sm alert-action" href="/iso">Загрузить</a></div>
<div class="alert warning" data-missing-source="disk_image" hidden>{{ icon('triangle-alert') }}<div><strong>Нет готовых образов</strong>Загрузите образ системы в формате .qcow2, .img или архив.</div><a class="btn btn-sm alert-action" href="/disk-images">Загрузить</a></div>
</div>
</article>
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Параметры</h2>
<span class="pill">VM wizard</span>
</div>
<form method="post" action="/vm/create" class="form-grid">
<label>
<span>Имя VM</span>
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-test" required>
<article class="card step-card">
<div class="step-head"><span class="step-num">2</span><div><h2>Мощность</h2><p>Сколько ресурсов сервера выделить машине. Можно поменять позже.</p></div></div>
<div class="step-body form-grid">
<div class="options options-4">
{% for p in vm_presets %}
<label class="option">
<input type="radio" name="preset" value="{{ p.id }}" data-vcpus="{{ p.vcpus }}" data-memory="{{ p.memory }}" data-disk="{{ p.disk_size }}" {% if preset_match.id == p.id %}checked{% endif %}>
<span class="option-body">
<span class="option-top"><span class="option-title">{{ p.title }}</span><span class="option-check"></span></span>
<span class="option-text">{{ p.hint }}</span>
<span class="option-specs"><span class="tag">{{ p.vcpus }} {{ plural(p.vcpus, 'ядро', 'ядра', 'ядер') }}</span><span class="tag">{{ (p.memory / 1024)|int }} ГБ ОЗУ</span><span class="tag">{{ p.disk_size }} ГБ</span></span>
</span>
</label>
{% endfor %}
<label class="option">
<input type="radio" name="preset" value="custom" {% if preset_match.id == 'custom' %}checked{% endif %}>
<span class="option-body">
<span class="option-top"><span class="option-title">Своя</span><span class="option-check"></span></span>
<span class="option-text">Задать вручную</span>
<span class="option-specs"><span class="tag">{{ icon('settings', 'icon-sm') }}Точная настройка</span></span>
</span>
</label>
</div>
<div class="form-row-3" data-custom-resources data-visible-when="preset=custom">
<div class="field"><label for="vcpus">Ядра</label><input class="input" id="vcpus" type="number" name="vcpus" min="1" max="128" value="{{ form.vcpus }}" required></div>
<div class="field"><label for="memory">Память, МБ</label><input class="input" id="memory" type="number" name="memory" min="512" max="262144" step="256" value="{{ form.memory }}" required></div>
<div class="field" data-show-when="source_type=iso"><label for="disk_size">Диск, ГБ</label><input class="input" id="disk_size" type="number" name="disk_size" min="4" max="4096" value="{{ form.disk_size }}" required></div>
</div>
{% if profile.virtualization_warning %}{{ alert('warning', 'На этом сервере нет аппаратного ускорения — машины будут заметно медленнее.') }}{% endif %}
</div>
</article>
<article class="card step-card">
<div class="step-head"><span class="step-num">3</span><div><h2>Сеть</h2><p>Как машина будет выходить в интернет</p></div></div>
<div class="step-body form-grid">
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(220px,1fr))">
<label class="option">
<input type="radio" name="network_mode" value="nat" {% if form.network_mode != 'bridge' %}checked{% endif %}>
<span class="option-body">
<span class="option-top"><span class="option-icon">{{ icon('shield-check') }}</span>{{ badge('Рекомендуем', 'brand', False) }}</span>
<span class="option-title">Автоматически</span>
<span class="option-text">Машина получит адрес сама и выйдет в интернет через сервер. Снаружи её не видно, пока вы не откроете доступ.</span>
</span>
</label>
<label class="option">
<input type="radio" name="network_mode" value="bridge" {% if form.network_mode == 'bridge' %}checked{% endif %} {% if not profile.bridge_available %}disabled{% endif %}>
<span class="option-body">
<span class="option-top"><span class="option-icon">{{ icon('network') }}</span><span class="option-check"></span></span>
<span class="option-title">В локальной сети</span>
<span class="option-text">{% if profile.bridge_available %}Машина станет отдельным устройством в вашей сети и получит адрес от роутера.{% else %}Недоступно: на сервере не настроен сетевой мост br0.{% endif %}</span>
</span>
</label>
</div>
<details class="advanced">
<summary>{{ icon('wrench', 'icon-sm') }}Дополнительные настройки<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body form-grid">
<div class="form-row">
<label>
<span>Источник VM</span>
<select id="source-type" name="source_type" required>
<option value="iso" {% if form.source_type|default('iso') != 'disk_image' %}selected{% endif %}>ISO-установщик</option>
{% if disk_feature_available %}
<option value="disk_image" {% if form.source_type|default('iso') == 'disk_image' %}selected{% endif %}>Готовый диск .img / .raw / .qcow2</option>
{% endif %}
<div class="field">
<label for="guest_arch">Архитектура</label>
<select class="select" id="guest_arch" name="guest_arch">
{% for arch in arch_options %}<option value="{{ arch.value }}" {% if form.guest_arch == arch.value %}selected{% endif %}>{{ arch.label }}</option>{% endfor %}
</select>
</label>
<label>
<span>Архитектура VM</span>
<select name="guest_arch" required>
{% if arch_options is defined and arch_options %}
{% for arch in arch_options %}
<option value="{{ arch.value }}" {% if form.guest_arch == arch.value %}selected{% endif %}>{{ arch.label }}</option>
{% endfor %}
{% else %}
<option value="x86_64" {% if form.guest_arch|default('x86_64') == 'x86_64' %}selected{% endif %}>x86_64 / amd64</option>
<option value="aarch64" {% if form.guest_arch|default('x86_64') == 'aarch64' %}selected{% endif %}>ARM64 / aarch64</option>
{% endif %}
</div>
<div class="field">
<label for="boot_order">Порядок загрузки</label>
<select class="select" id="boot_order" name="boot_order">
{% for opt in boot_options %}<option value="{{ opt.value }}" {% if form.boot_order == opt.value %}selected{% endif %}>{{ opt.label }}</option>{% endfor %}
</select>
</label>
</div>
</div>
<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 id="disk-size-row">
<span>Диск, GB</span>
<input type="number" name="disk_size" min="4" max="4096" value="{{ form.disk_size }}" required>
</label>
<label id="iso-source-row">
<span id="source-image-label">ISO образ</span>
<select name="iso_path" {% if isos %}required{% endif %}>
{% for iso in isos %}
<option value="{{ iso.path }}" {% if form.iso_path == iso.path %}selected{% endif %}>{{ iso.name }} — {{ iso.size }}</option>
{% endfor %}
</select>
</label>
{% if disk_feature_available %}
<div id="disk-image-source-row" class="form-subcard">
<label>
<span id="disk-image-label">Готовый образ диска</span>
<select name="disk_image_path">
{% for image in disk_images %}
<option value="{{ image.path }}" {% if form.disk_image_path == image.path %}selected{% endif %}>{{ image.name }} — {{ image.size }} — {{ image.format }}</option>
{% endfor %}
</select>
</label>
<label class="check-row">
<input type="checkbox" name="replace_existing_disk" value="1" {% if form.replace_existing_disk == '1' %}checked{% endif %}>
<span>Заменить существующий диск VM, если остался после неудачной установки</span>
</label>
<div class="muted small-note">Опция нужна, если предыдущая попытка создания VM оборвалась и оставила хвост в /var/lib/virtuality/images.</div>
</div>
{% endif %}
<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 id="source-mode-notice" class="notice">
Для обычных ISO выбирай x86_64 на ПК/VPS. Для Raspberry/Orange Pi образов обычно нужен ARM64/aarch64 и источник “Готовый диск”.
</div>
<button id="create-vm-button" type="submit" class="primary wide">Создать виртуальную машину</button>
{% if not isos %}
<div id="iso-missing-warning" class="alert danger">Для режима ISO сначала загрузи ISO-образ в ISO-менеджере.</div>
{% endif %}
{% if disk_feature_available and not disk_images %}
<div id="disk-image-missing-warning" class="alert danger">Для режима готового диска сначала загрузи .img, .raw или .qcow2 в разделе «Диски».</div>
{% endif %}
</form>
</article>
<article class="card">
<div class="card-head">
<h2>Архитектура и источник</h2>
<a class="link-pill" href="/disk-images">Открыть диски</a>
</div>
<div class="quick-actions">
<div class="big-action static">
<strong>x86_64 / amd64</strong>
<span>Обычные ПК, серверы, большинство VPS и стандартные Ubuntu/Debian ISO.</span>
</div>
<div class="big-action static">
<strong>ARM64 / aarch64</strong>
<span>На x86-хосте работает как QEMU-эмуляция из готового диска .img/.qcow2. Это медленно, но честно.</span>
</div>
<div class="big-action static">
<strong>Готовый диск</strong>
<span>Опция показывается только если backend передал список готовых образов. Так форма не уходит в поломанное состояние.</span>
<div class="field">
<label for="bridge">Сетевой мост для режима «В локальной сети»</label>
<input class="input" id="bridge" type="text" name="bridge" value="{{ form.bridge or 'br0' }}" placeholder="br0">
</div>
</div>
<pre>ISO x86: virt-install --arch x86_64 --cdrom ubuntu.iso
IMG ARM: qemu-img convert -O qcow2 image.img VM.qcow2
virsh define /tmp/virtuality-VM-arm64.xml && virsh start VM</pre>
</article>
</section>
</main>
</details>
</div>
</article>
</div>
<script>
const sourceType = document.getElementById('source-type');
const isoRow = document.getElementById('iso-source-row');
const diskImageRow = document.getElementById('disk-image-source-row');
const diskSizeRow = document.getElementById('disk-size-row');
const sourceImageLabel = document.getElementById('source-image-label');
const diskImageLabel = document.getElementById('disk-image-label');
const sourceModeNotice = document.getElementById('source-mode-notice');
const isoWarning = document.getElementById('iso-missing-warning');
const diskWarning = document.getElementById('disk-image-missing-warning');
const createButton = document.getElementById('create-vm-button');
const hasIsos = {{ 'true' if isos else 'false' }};
const diskFeatureAvailable = {{ 'true' if disk_feature_available else 'false' }};
const hasDiskImages = {{ 'true' if disk_images_available else 'false' }};
function refreshSourceUI() {
const isDisk = diskFeatureAvailable && sourceType.value === 'disk_image';
isoRow.style.display = isDisk ? 'none' : 'grid';
if (diskImageRow) diskImageRow.style.display = isDisk ? 'grid' : 'none';
sourceImageLabel.textContent = isDisk ? 'Готовый образ диска' : 'ISO образ';
if (diskImageLabel) diskImageLabel.textContent = 'Готовый образ диска';
diskSizeRow.style.opacity = isDisk ? '.45' : '1';
diskSizeRow.querySelector('span').textContent = isDisk ? 'Диск, GB — не используется при импорте' : 'Диск, GB';
sourceModeNotice.textContent = isDisk
? 'Выбран источник “Готовый диск”: Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется.'
: 'Выбран источник “ISO-установщик”: Virtuality создаст новый диск указанного размера и запустит установку с ISO.';
if (isoWarning) isoWarning.style.display = !isDisk && !hasIsos ? 'block' : 'none';
if (diskWarning) diskWarning.style.display = isDisk && !hasDiskImages ? 'block' : 'none';
createButton.disabled = isDisk ? !hasDiskImages : !hasIsos;
}
sourceType.addEventListener('change', refreshSourceUI);
refreshSourceUI();
</script>
<script src="/static/panel.js" defer></script>
</body>
</html>
<aside class="summary">
<article class="card">
<div class="card-header"><h2>Итого</h2></div>
<div class="card-body" style="padding-top:8px;padding-bottom:8px">
<div class="summary-row"><span>Название</span><strong data-summary="name">—</strong></div>
<div class="summary-row"><span>Система</span><strong data-summary="source">—</strong></div>
<div class="summary-row"><span>Процессор</span><strong data-summary="cpu">—</strong></div>
<div class="summary-row"><span>Память</span><strong data-summary="memory">—</strong></div>
<div class="summary-row"><span>Диск</span><strong data-summary="disk">—</strong></div>
<div class="summary-row"><span>Сеть</span><strong data-summary="network">—</strong></div>
</div>
<div class="card-footer" style="display:grid">
<button type="submit" class="btn btn-primary btn-lg btn-block" data-create-button data-busy="Создаём машину…">{{ icon('zap') }}Создать машину</button>
<p class="subtle small" style="text-align:center">Вы увидите прогресс, а затем сможете открыть экран машины</p>
</div>
</article>
</aside>
</form>
{% endblock %}
+227 -379
Просмотреть файл
@@ -1,389 +1,237 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ vm.name }} — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
<style>
.vm-four-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: stretch; margin-bottom: 14px; }
.vm-four-grid .card { min-width: 0; height: calc(100% - 14px); }
.vm-four-grid .card-head { align-items: flex-start; }
.vm-four-grid h2 { font-size: 16px; line-height: 1.2; }
.vm-four-grid .form-row { grid-template-columns: 1fr; }
.vm-four-grid .compact-actions { justify-content: flex-start; }
.vm-four-grid .vm-control-panel { display: grid; grid-template-columns: 1fr; gap: 12px; }
.vm-four-grid .autostart-indicator { width: 100%; }
.vm-visual-grid { grid-template-columns: 1.15fr 1fr 1fr; align-items: stretch; margin-bottom: 14px; }
.visual-card-body { display: grid; gap: 12px; }
.metric-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.metric-tile { padding: 12px; border: 1px solid var(--line); border-radius: 16px; background: rgba(2,6,23,.32); min-width: 0; }
.metric-tile strong { display: block; margin-top: 5px; font-size: 20px; line-height: 1.1; letter-spacing: -.04em; overflow-wrap: anywhere; }
.metric-tile span { color: var(--muted); font-size: 11px; font-weight: 900; letter-spacing: .1em; text-transform: uppercase; }
.chart-row { display: grid; grid-template-columns: 96px 1fr; gap: 14px; align-items: center; }
.donut { --value: 0; --donut-color: var(--accent); width: 96px; height: 96px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--donut-color) calc(var(--value) * 1%), rgba(148,163,184,.16) 0); position: relative; }
.donut::before { content: ""; position: absolute; inset: 12px; border-radius: 50%; background: var(--panel-solid); opacity: .95; }
.donut-value { position: relative; z-index: 1; font-size: 18px; font-weight: 950; }
.bar-list { display: grid; gap: 10px; }
.bar-item { display: grid; gap: 6px; }
.bar-head { display: flex; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 12px; font-weight: 800; }
.mini-bar { height: 9px; overflow: hidden; border: 1px solid var(--line); border-radius: 999px; background: rgba(148,163,184,.12); }
.mini-bar span { display: block; height: 100%; width: var(--value, 0%); border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--green)); }
.visual-list { display: grid; gap: 9px; }
.visual-item { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 11px; border: 1px solid var(--line); border-radius: 15px; background: rgba(2,6,23,.32); }
.visual-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 12px; background: rgba(56,189,248,.13); color: var(--accent); font-weight: 950; }
.visual-item b { display: block; font-size: 14px; overflow-wrap: anywhere; }
.visual-item small { color: var(--muted); line-height: 1.35; }
.visual-empty { padding: 12px; border: 1px dashed var(--line-strong); border-radius: 15px; color: var(--muted); }
.raw-details { margin-top: 12px; }
.raw-details summary { cursor: pointer; color: var(--muted); font-weight: 900; }
.raw-details pre { max-height: 260px; }
html[data-theme="macos"] .metric-tile, html[data-theme="macos"] .visual-item { background: #f7f7fa; border-color: rgba(60,60,67,.10); }
html[data-theme="macos"] .donut::before { background: #fff; }
html[data-theme="macos"] .visual-empty { border-color: rgba(60,60,67,.18); }
@media (max-width: 1500px) { .vm-four-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .vm-visual-grid { grid-template-columns: 1fr; } }
@media (max-width: 980px) { .vm-four-grid, .metric-strip { grid-template-columns: 1fr; } .chart-row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="v-layout">
{% set is_running = 'running' in vm.dominfo|lower %}
{% set is_autostart = vm.autostart_enabled|default(false) %}
{% set autostart_label = 'enabled' if is_autostart else 'disabled' %}
{% set rs = resource_settings|default({}) %}
{% include "_sidebar.html" %}
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, vm_badge, alert %}
{% set page_title = vm.name %}
{% set s = (vm.info.state or ('running' if 'running' in vm.dominfo|lower else 'shut off'))|vm_state %}
{% set rs = resource_settings|default({}) %}
{% set has_ip = vm.ip and vm.ip not in ('—', 'not available') %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">{{ vm.name }}</div>
<div class="v-subtitle">Детали виртуальной машины</div>
{% block page_header %}
<div class="page-header" data-live-vm="{{ vm.name }}" data-running="{{ '1' if s.running else '0' }}" data-reload-on-change>
<div>
<div class="eyebrow"><a href="/">{{ icon('arrow-left', 'icon-sm') }} Все машины</a></div>
<div class="title-row">
<span class="vm-avatar {{ 'on' if s.running else '' }}" style="width:48px;height:48px">{{ icon('monitor', 'icon-lg') }}<span class="status-dot {{ s.tone }}" data-live="dot"></span></span>
<div>
<div class="row"><h1>{{ vm.name }}</h1>{{ vm_badge(vm.info.state or s.raw, 'lg') }}</div>
<p class="subtle mt-1">{{ vm.info.vcpus }} {{ plural(vm.info.vcpus, 'ядро', 'ядра', 'ядер') }} · {{ vm.info.memory_mb|format_mb }} памяти{% if has_ip %} · <span data-live="ip">{{ vm.ip }}</span>{% endif %}</p>
</div>
</div>
</div>
<div class="page-actions">
{% 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>
{% 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">
{% 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>
{% 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 %}
</div>
</details>
</div>
</div>
{% endblock %}
{% block alerts %}
{% for key in ('resource', 'iso', 'boot') %}
{% set msg = {'resource': resource_message, 'iso': iso_message, 'boot': boot_message}[key] %}
{% set err = {'resource': resource_error, 'iso': iso_error, 'boot': boot_error}[key] %}
{% if msg %}{{ alert('success', msg) }}{% endif %}
{% if err %}{{ alert('danger', err) }}{% endif %}
{% endfor %}
{% endblock %}
{% block content %}
<nav class="tabs" data-tabs role="tablist">
<button type="button" data-tab="overview" role="tab">{{ icon('layout-dashboard', 'icon-sm') }}Обзор</button>
<button type="button" data-tab="resources" role="tab">{{ icon('cpu', 'icon-sm') }}Ресурсы</button>
<button type="button" data-tab="storage" role="tab">{{ icon('hard-drive', 'icon-sm') }}Диски и загрузка</button>
<button type="button" data-tab="expert" role="tab">{{ icon('terminal', 'icon-sm') }}Для специалистов</button>
</nav>
<section data-tab-panel="overview" class="grid grid-main">
<div class="stack">
<article class="card">
<div class="card-header"><h2>Сводка</h2></div>
<div class="card-body">
<div class="kv">
<div><span>Состояние</span><strong data-live-vm="{{ vm.name }}">{{ vm_badge(vm.info.state or s.raw) }}</strong></div>
<div><span>IP-адрес</span><strong>{% if has_ip %}{{ vm.ip }} <button type="button" class="btn btn-ghost btn-icon btn-sm" data-copy="{{ vm.ip }}" title="Скопировать" style="height:22px;width:22px;vertical-align:middle">{{ icon('copy', 'icon-sm') }}</button>{% else %}<span class="subtle">{{ 'Определяется…' if s.running else 'Нет — машина выключена' }}</span>{% endif %}</strong></div>
<div><span>Процессор</span><strong>{{ vm.info.vcpus }} {{ plural(vm.info.vcpus, 'ядро', 'ядра', 'ядер') }}</strong></div>
<div><span>Память</span><strong>{{ vm.info.memory_mb|format_mb }}</strong></div>
<div><span>Архитектура</span><strong>{{ rs.arch|default('—') }}</strong></div>
<div><span>Сеть</span><strong>{{ (vm.interface_list[0].source if vm.interface_list else '—')|replace('virtuality-nat', 'Автоматически (NAT)') }}</strong></div>
</div>
<div class="top-meta">
<span>user: {{ user }}</span>
<span class="status {{ 'ok' if is_running else 'err' }}">{{ 'running' if is_running else 'shut off' }}</span>
<span class="status {{ 'ok' if is_autostart else 'err' }}">autostart: {{ autostart_label }}</span>
<a class="link-pill accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">Консоль ↗</a>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</article>
<article class="card">
<div class="card-header"><div><h2>Как подключиться</h2><p>Выберите удобный способ</p></div></div>
<div class="list">
<div class="list-item">
<span class="list-icon brand">{{ icon('monitor') }}</span>
<div class="grow"><div class="title">Экран в браузере</div><div class="meta">Как монитор, подключённый к машине. Подходит для установки системы.</div></div>
{% if s.running %}<a class="btn btn-sm" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">Открыть{{ icon('external-link', 'icon-sm') }}</a>{% else %}<span class="subtle small">Запустите машину</span>{% endif %}
</div>
</header>
{% if resource_message %}<div class="alert success">{{ resource_message }}</div>{% endif %}
{% if resource_error %}<div class="alert danger">{{ resource_error }}</div>{% endif %}
{% if iso_message %}<div class="alert success">{{ iso_message }}</div>{% endif %}
{% 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 %}
<section class="grid vm-four-grid">
<article class="card">
<div class="card-head">
<h2>Управление</h2>
<span class="pill">actions</span>
</div>
<div class="vm-control-panel compact-control-panel">
<div>
<div class="label">Основные действия</div>
<div class="vm-actions compact-actions big-actions">
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Открыть консоль"><span>▣</span><em>Консоль</em></a>
{% if is_running %}
<form method="post" action="/vm/{{ vm.name }}/reboot"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/shutdown"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/start"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
{% endif %}
</div>
</div>
<div>
<div class="label">Автозапуск</div>
<div class="vm-actions compact-actions big-actions">
{% if is_autostart %}
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="autostart-form">
<button class="autostart-indicator on" title="Автозапуск включён. Нажми, чтобы отключить"><span>●</span><strong>Включён</strong><small>Нажми для отключения</small></button>
</form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/autostart" class="autostart-form">
<button class="autostart-indicator off" title="Автозапуск выключен. Нажми, чтобы включить"><span>●</span><strong>Выключен</strong><small>Нажми для включения</small></button>
</form>
{% endif %}
</div>
<p class="muted small-note">Автозапуск поднимает VM вместе с libvirt после перезагрузки хоста.</p>
</div>
<div class="danger-zone-inline">
<div class="label">Опасные действия</div>
<div class="vm-actions compact-actions big-actions">
{% if is_running %}
<form method="post" action="/vm/{{ vm.name }}/destroy"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power off</em></button></form>
{% endif %}
{% if not is_running %}
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><button class="icon-action danger" title="Удалить VM вместе с дисками"><span>🗑</span><em>Delete</em></button></form>
{% else %}
<span class="muted small-note">Удаление доступно после выключения VM.</span>
{% endif %}
</div>
</div>
</div>
</article>
<article class="card">
<div class="card-head">
<h2>CPU / RAM / Архитектура</h2>
<span class="pill">resources</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid">
<div class="form-row">
<label>
<span>CPU</span>
<input type="number" name="vcpus" min="1" max="128" value="{{ rs.vcpus|default(1) }}" {% if is_running %}disabled{% endif %} required>
</label>
<label>
<span>RAM, MB</span>
<input type="number" name="memory_mb" min="512" max="262144" step="512" value="{{ rs.memory_mb|default(1024) }}" {% if is_running %}disabled{% endif %} required>
</label>
</div>
<label>
<span>Архитектура</span>
<select name="guest_arch" {% if is_running %}disabled{% endif %}>
<option value="keep">Не менять — {{ rs.arch|default('unknown') }}</option>
<option value="x86_64">x86_64 / amd64</option>
<option value="aarch64">ARM64 / aarch64</option>
</select>
</label>
<button class="primary wide" type="submit" {% if is_running %}disabled{% endif %}>Сохранить ресурсы VM</button>
</form>
{% if is_running %}
<p class="muted small-note">CPU/RAM/архитектуру можно менять только при выключенной VM.</p>
{% else %}
<p class="muted small-note">Изменения применяются через XML VM и virsh define.</p>
{% endif %}
</article>
<article class="card">
<div class="card-head">
<h2>Порядок загрузки</h2>
<span class="pill">boot</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form">
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order|default('auto') }}">
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order|default('auto') }}">
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle">☰</span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
<div class="boot-order-item" draggable="true" data-device="hd"><span class="drag-handle">☰</span><div><b>Диск</b><small>Основной диск VM</small></div></div>
<div class="boot-order-item" draggable="true" data-device="network"><span class="drag-handle">☰</span><div><b>Сеть / PXE</b><small>Загрузка по сети</small></div></div>
</div>
<button class="primary wide" type="submit">Применить порядок</button>
</form>
<p class="muted small-note">Изменение работает после перезапуска VM.</p>
</article>
<article class="card">
<div class="card-head">
<h2>ISO-привод</h2>
<span class="pill">cdrom</span>
</div>
{% if current_iso %}
<div class="notice">Сейчас подключен ISO: <b>{{ current_iso }}</b></div>
{% else %}
<div class="muted small-note">ISO сейчас не подключён.</div>
{% endif %}
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid">
<label>
<span>ISO образ</span>
<select name="iso_path" required>
{% for iso in isos|default([]) %}
<option value="{{ iso.path }}" {% if current_iso == iso.path %}selected{% endif %}>{{ iso.name }} — {{ iso.size }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO</button>
</form>
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');">
<button type="submit" class="ghost wide" {% if not current_iso %}disabled{% endif %}>Отмонтировать ISO</button>
</form>
{% if not isos %}
<div class="alert danger">ISO-образов пока нет. Сначала загрузи .iso в разделе ISO.</div>
{% endif %}
</article>
</section>
<section class="grid vm-visual-grid">
<article class="card">
<div class="card-head">
<h2>Информация</h2>
<span class="pill">overview</span>
</div>
<div class="visual-card-body" id="vm-info-visual"></div>
<details class="raw-details">
<summary>Технический вывод dominfo</summary>
<pre>{{ vm.dominfo }}</pre>
</details>
</article>
<article class="card">
<div class="card-head">
<h2>Диски</h2>
<span class="pill">storage</span>
</div>
<div class="visual-card-body" id="vm-disk-visual"></div>
<details class="raw-details">
<summary>Технический вывод domblklist</summary>
<pre>{{ vm.disks }}</pre>
</details>
</article>
<article class="card">
<div class="card-head">
<h2>Сеть</h2>
<span class="pill">network</span>
</div>
<div class="visual-card-body" id="vm-network-visual"></div>
<details class="raw-details">
<summary>Технический вывод domiflist</summary>
<pre>{{ vm.interfaces }}</pre>
</details>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Консоль</h2>
<a class="link-pill accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">noVNC ↗</a>
<div class="list-item">
<span class="list-icon">{{ icon('terminal') }}</span>
<div class="grow"><div class="title">SSH с этого сервера</div><div class="meta">{% if has_ip %}<code>ssh пользователь@{{ vm.ip }}</code>{% else %}Станет доступно, когда машина получит IP-адрес{% endif %}</div></div>
</div>
<div class="services">
<div class="service-row"><span>VNC display</span><span class="status ok">{{ vm.vnc }}</span></div>
<div class="service-row"><span>Host</span><span class="status ok">{{ host_ip }}</span></div>
<div class="service-row"><span>Web console</span><span class="status ok">/vm/{{ vm.name }}/console</span></div>
<div class="list-item">
<span class="list-icon">{{ icon('globe') }}</span>
<div class="grow"><div class="title">Доступ из интернета</div><div class="meta">Откройте порт машины наружу: сайт, SSH, удалённый рабочий стол</div></div>
<a class="btn btn-sm" href="/network">Настроить</a>
</div>
<p class="muted small-note">Консоль открывается в новой вкладке и занимает всё окно браузера. VNC наружу открывать не нужно.</p>
</section>
</main>
</div>
</article>
</div>
<template id="vm-dominfo-raw">{{ vm.dominfo }}</template>
<template id="vm-disks-raw">{{ vm.disks }}</template>
<template id="vm-interfaces-raw">{{ vm.interfaces }}</template>
<div class="stack">
<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 'Машина будет запускаться вместе с сервером' }}">
<div class="switch-text"><strong>Автозапуск</strong><span>Запускать машину вместе с сервером</span></div>
<button type="submit" class="switch" role="switch" aria-checked="{{ 'true' if vm.autostart_enabled else 'false' }}" aria-label="Автозапуск"></button>
</form>
</div>
</article>
<script>
(function(){
const list = document.getElementById('boot-order-list');
const input = document.getElementById('boot-order-value');
if (!list || !input) return;
const initialMap = { cdrom_disk: ['cdrom','hd','network'], disk_cdrom: ['hd','cdrom','network'], network_disk: ['network','hd','cdrom'], disk: ['hd','cdrom','network'], auto: ['hd','cdrom','network'] };
const order = initialMap[list.dataset.current || 'auto'] || initialMap.auto;
const nodes = Array.from(list.querySelectorAll('.boot-order-item'));
order.forEach(device => { const node = nodes.find(item => item.dataset.device === device); if (node) list.appendChild(node); });
function updateValue(){
const devices = Array.from(list.querySelectorAll('.boot-order-item')).map(item => item.dataset.device);
const first = devices[0]; const second = devices[1];
if (first === 'cdrom' && second === 'hd') input.value = 'cdrom_disk';
else if (first === 'hd' && second === 'cdrom') input.value = 'disk_cdrom';
else if (first === 'network' && second === 'hd') input.value = 'network_disk';
else if (first === 'hd') input.value = 'disk';
else input.value = 'auto';
}
let dragged = null;
list.addEventListener('dragstart', event => { dragged = event.target.closest('.boot-order-item'); if (!dragged) return; dragged.classList.add('dragging'); event.dataTransfer.effectAllowed = 'move'; });
list.addEventListener('dragend', () => { if (dragged) dragged.classList.remove('dragging'); dragged = null; updateValue(); });
list.addEventListener('dragover', event => {
event.preventDefault();
const after = Array.from(list.querySelectorAll('.boot-order-item:not(.dragging)')).find(item => {
const box = item.getBoundingClientRect();
return event.clientY < box.top + box.height / 2;
});
if (!dragged) return;
if (after) list.insertBefore(dragged, after); else list.appendChild(dragged);
});
updateValue();
})();
<article class="card">
<div class="card-header"><h2>Установочный диск</h2></div>
<div class="card-body stack-sm">
{% if current_iso %}
<div class="upload-file"><span class="list-icon brand">{{ icon('disc-3') }}</span><div class="grow"><strong class="truncate" style="display:block">{{ current_iso.split('/')[-1] }}</strong><span class="subtle small">Вставлен в привод</span></div></div>
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" data-confirm="Образ будет извлечён из привода машины." data-confirm-title="Извлечь диск?" data-confirm-button="Извлечь" data-confirm-tone="brand"><button class="btn btn-sm btn-block">{{ icon('x', 'icon-sm') }}Извлечь</button></form>
{% else %}
<p class="subtle small">Привод пуст. Вставьте образ, чтобы установить систему или загрузиться с диска восстановления.</p>
<a class="btn btn-sm btn-block" href="#storage" onclick="document.querySelector('[data-tab=storage]').click()">{{ icon('disc-3', 'icon-sm') }}Вставить образ</a>
{% endif %}
</div>
</article>
</div>
</section>
(function(){
function text(id) { const node = document.getElementById(id); return node ? node.textContent.trim() : ''; }
function escapeHtml(value) { return String(value || '').replace(/[&<>"]/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[ch])); }
function parseKeyValues(raw) {
const data = {};
raw.split('\n').forEach(line => {
const idx = line.indexOf(':');
if (idx < 0) return;
const key = line.slice(0, idx).trim().toLowerCase();
const value = line.slice(idx + 1).trim();
if (key) data[key] = value;
});
return data;
}
function kibToGiB(value) {
const n = Number(String(value || '').replace(/[^0-9.]/g, ''));
if (!Number.isFinite(n) || n <= 0) return 0;
return n / 1024 / 1024;
}
function renderInfo() {
const box = document.getElementById('vm-info-visual');
if (!box) return;
const data = parseKeyValues(text('vm-dominfo-raw'));
const cpu = data['cpu(s)'] || data['cpu'] || '—';
const state = data['state'] || 'unknown';
const osType = data['os type'] || '—';
const maxMem = kibToGiB(data['max memory']);
const usedMem = kibToGiB(data['used memory']);
const memPct = maxMem > 0 ? Math.max(0, Math.min(100, Math.round((usedMem / maxMem) * 100))) : 0;
const cpuPct = Math.max(8, Math.min(100, Number(cpu) ? Number(cpu) * 12 : 18));
box.innerHTML = `
<div class="metric-strip">
<div class="metric-tile"><span>Состояние</span><strong>${escapeHtml(state)}</strong></div>
<div class="metric-tile"><span>CPU</span><strong>${escapeHtml(cpu)}</strong></div>
<div class="metric-tile"><span>Тип ОС</span><strong>${escapeHtml(osType)}</strong></div>
<section data-tab-panel="resources" hidden>
<article class="card" style="max-width:720px">
<div class="card-header"><div><h2>Процессор и память</h2><p>Сколько ресурсов сервера получает машина</p></div></div>
<form method="post" action="/vm/{{ vm.name }}/resources" data-toast="Настройки сохранены">
<div class="card-body form-grid">
{% if not rs.is_shutoff %}
{{ alert('info', 'Выключите машину, чтобы изменить процессор и память.') }}
{% endif %}
<div class="form-row">
<div class="field">
<label for="vcpus">Ядра процессора</label>
<input class="input" id="vcpus" type="number" name="vcpus" min="1" max="128" value="{{ rs.vcpus|default(1) }}" {% if not rs.is_shutoff %}disabled{% endif %} required>
</div>
<div class="chart-row">
<div class="donut" style="--value:${memPct};--donut-color:var(--accent)"><div class="donut-value">${memPct}%</div></div>
<div class="bar-list">
<div class="bar-item"><div class="bar-head"><span>Память</span><b>${usedMem ? usedMem.toFixed(1) : '—'} / ${maxMem ? maxMem.toFixed(1) : '—'} GB</b></div><div class="mini-bar"><span style="--value:${memPct}%"></span></div></div>
<div class="bar-item"><div class="bar-head"><span>vCPU capacity</span><b>${escapeHtml(cpu)} vCPU</b></div><div class="mini-bar"><span style="--value:${cpuPct}%"></span></div></div>
<div class="bar-item"><div class="bar-head"><span>Автозапуск</span><b>${escapeHtml(data['autostart'] || '—')}</b></div><div class="mini-bar"><span style="--value:${String(data['autostart'] || '').includes('enable') ? 100 : 18}%"></span></div></div>
</div>
</div>`;
}
function renderDisks() {
const box = document.getElementById('vm-disk-visual');
if (!box) return;
const lines = text('vm-disks-raw').split('\n').map(line => line.trim()).filter(Boolean);
const rows = [];
lines.forEach(line => {
if (/^(type|[-\s]+$)/i.test(line)) return;
const parts = line.split(/\s+/);
if (parts.length < 4) return;
rows.push({ type: parts[0], device: parts[1], target: parts[2], source: parts.slice(3).join(' ') });
});
if (!rows.length) { box.innerHTML = '<div class="visual-empty">Диски не найдены в выводе libvirt.</div>'; return; }
box.innerHTML = `<div class="visual-list">${rows.map(row => `
<div class="visual-item">
<div class="visual-icon">▣</div>
<div><b>${escapeHtml(row.target)}</b><small>${escapeHtml(row.device)} · ${escapeHtml(row.type)}<br>${escapeHtml(row.source)}</small></div>
<span class="status ok">disk</span>
</div>`).join('')}</div>`;
}
function renderNetwork() {
const box = document.getElementById('vm-network-visual');
if (!box) return;
const lines = text('vm-interfaces-raw').split('\n').map(line => line.trim()).filter(Boolean);
const rows = [];
lines.forEach(line => {
if (/^(interface|[-\s]+$)/i.test(line)) return;
const parts = line.split(/\s+/);
if (parts.length < 5) return;
rows.push({ iface: parts[0], type: parts[1], source: parts[2], model: parts[3], mac: parts[4] });
});
if (!rows.length) { box.innerHTML = '<div class="visual-empty">Сетевые интерфейсы не найдены в выводе libvirt.</div>'; return; }
box.innerHTML = `<div class="visual-list">${rows.map(row => `
<div class="visual-item">
<div class="visual-icon">⇄</div>
<div><b>${escapeHtml(row.iface)}</b><small>${escapeHtml(row.type)} · ${escapeHtml(row.source)} · ${escapeHtml(row.model)}<br>${escapeHtml(row.mac)}</small></div>
<span class="status ok">link</span>
</div>`).join('')}</div>`;
}
renderInfo();
renderDisks();
renderNetwork();
})();
</script>
<script src="/static/panel.js" defer></script>
</body>
</html>
<div class="field">
<label for="memory_mb">Память</label>
<div class="input-group"><input class="input" id="memory_mb" type="number" name="memory_mb" min="512" max="262144" step="512" value="{{ rs.memory_mb|default(1024) }}" {% if not rs.is_shutoff %}disabled{% endif %} required><span class="addon">МБ</span></div>
<span class="hint">1024 МБ = 1 ГБ</span>
</div>
</div>
<details class="advanced">
<summary>{{ icon('wrench', 'icon-sm') }}Архитектура процессора<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body field">
<select class="select" name="guest_arch" {% if not rs.is_shutoff %}disabled{% endif %}>
<option value="keep">Не менять — {{ rs.arch|default('неизвестно') }}</option>
<option value="x86_64">x86_64 — обычные ПК и серверы</option>
<option value="aarch64">ARM64 — образы для Raspberry Pi и ARM-серверов</option>
</select>
<span class="hint">Меняйте, только если точно знаете, зачем: система внутри может перестать загружаться.</span>
</div>
</details>
</div>
<div class="card-footer"><button type="submit" class="btn btn-primary" {% if not rs.is_shutoff %}disabled{% endif %}>Сохранить</button></div>
</form>
</article>
</section>
<section data-tab-panel="storage" class="grid grid-2" hidden>
<article class="card">
<div class="card-header"><div><h2>Порядок загрузки</h2><p>С чего машина пытается загрузиться в первую очередь</p></div></div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" data-toast="Порядок загрузки сохранён">
<input type="hidden" name="boot_order" value="{{ current_boot_order|default('auto') }}" data-boot-value>
<div class="list" data-boot-list data-current="{{ current_boot_order|default('auto') }}">
{% for dev, title, hint, ic in [('hd', 'Жёсткий диск', 'Установленная система', 'hard-drive'), ('cdrom', 'Установочный диск', 'ISO-образ в приводе', 'disc-3'), ('network', 'Сеть (PXE)', 'Загрузка по сети', 'network')] %}
<div class="list-item" data-device="{{ dev }}">
<span class="list-icon"><b data-position>{{ loop.index }}</b></span>
{{ icon(ic) }}
<div class="grow"><div class="title">{{ title }}</div><div class="meta">{{ hint }}</div></div>
<button type="button" class="btn btn-ghost btn-icon btn-sm" data-move="up" aria-label="Выше"><svg class="icon icon-sm" style="transform:rotate(180deg)"><use href="/static/icons.svg#i-chevron-down"></use></svg></button>
<button type="button" class="btn btn-ghost btn-icon btn-sm" data-move="down" aria-label="Ниже">{{ icon('chevron-down', 'icon-sm') }}</button>
</div>
{% endfor %}
</div>
<div class="card-footer"><span class="subtle small" style="margin-right:auto">Применится при следующем запуске</span><button type="submit" class="btn btn-primary btn-sm">Сохранить порядок</button></div>
</form>
</article>
<article class="card">
<div class="card-header"><div><h2>Привод CD/DVD</h2><p>Вставьте образ, чтобы установить систему</p></div></div>
<div class="card-body stack-sm">
{% if isos %}
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="stack-sm" data-toast="Образ вставлен в привод">
<div class="field">
<label for="iso_path">Образ диска</label>
<select class="select" id="iso_path" name="iso_path" required>
{% for iso in isos %}<option value="{{ iso.path }}" {% if current_iso == iso.path %}selected{% endif %}>{{ iso.name }} · {{ iso.size }}</option>{% endfor %}
</select>
</div>
<div class="row">
<button class="btn btn-primary btn-sm" type="submit">{{ icon('disc-3', 'icon-sm') }}Вставить</button>
{% if current_iso %}<button class="btn btn-sm" type="submit" form="iso-eject">Извлечь текущий</button>{% endif %}
</div>
</form>
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" id="iso-eject" data-toast="Образ извлечён"></form>
{% else %}
<p class="subtle">Образов пока нет.</p>
<a class="btn btn-sm" href="/iso">{{ icon('cloud-upload', 'icon-sm') }}Загрузить образ</a>
{% endif %}
</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>
<article class="card">
<div class="card-header"><h2>Сетевые карты</h2></div>
<div class="list">
{% for nic in vm.interface_list %}
<div class="list-item"><span class="list-icon">{{ icon('network') }}</span><div class="grow"><div class="title">{{ nic.source|replace('virtuality-nat', 'Автоматическая сеть (NAT)') }}</div><div class="meta mono">{{ nic.mac }} · {{ nic.model }}</div></div></div>
{% else %}
<div class="list-item subtle">Сетевых карт не найдено</div>
{% endfor %}
</div>
</article>
</section>
<section data-tab-panel="expert" class="stack" hidden>
<div class="kv">
<div><span>VNC-дисплей</span><strong class="mono">{{ vm.vnc }}</strong></div>
<div><span>Адрес сервера</span><strong class="mono">{{ host_ip }}</strong></div>
<div><span>UUID</span><strong class="mono small">{{ vm.info.uuid or '—' }}</strong></div>
</div>
{% for title, cmd, body in [('Информация о машине', 'virsh dominfo ' ~ vm.name, vm.dominfo), ('Диски', 'virsh domblklist ' ~ vm.name ~ ' --details', vm.disks), ('Сетевые интерфейсы', 'virsh domiflist ' ~ vm.name, vm.interfaces)] %}
<div>
<div class="terminal-head"><i></i><i></i><i></i><span style="margin-left:6px">{{ title }} — {{ cmd }}</span></div>
<pre class="terminal">{{ body or '—' }}</pre>
</div>
{% endfor %}
</section>
{% endblock %}