diff --git a/README.md b/README.md index 6bbeeb2..ed72360 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,24 @@ generic-arm64 # другая ARM64-плата Для ARM64-плат правильный сценарий — **ARM64-гости**, NAT-сеть и позже cloud-image/cloud-init шаблоны. x86_64 ISO на Raspberry/Orange Pi не являются целевым режимом. +![Обзор](docs/screenshots/dashboard.png) + +| Мастер создания машины | Страница машины | +|---|---| +| ![Создание машины](docs/screenshots/create.png) | ![Машина](docs/screenshots/vm.png) | + +| Сеть и доступ | Тёмная тема | +|---|---| +| ![Сеть](docs/screenshots/network.png) | ![Тёмная тема](docs/screenshots/dashboard-dark.png) | + --- ## Установка с ISO-образа -Для установки на «голое» железо собирается загрузочный ISO на базе Ubuntu Server 24.04: установщик спрашивает только сеть, диск и пользователя, а при первой загрузке нода настраивается сама. +Для установки на «голое» железо собирается загрузочный ISO на базе чистого Ubuntu Server 26.04 LTS: установщик спрашивает только сеть, диск и пользователя, а при первой загрузке нода настраивается сама. ```bash -make iso # dist/virtuality-<версия>-ubuntu-24.04-amd64.iso +make iso # dist/virtuality-<версия>-ubuntu-26.04-amd64.iso make iso ARCH=arm64 ``` @@ -138,7 +148,7 @@ CPU: 2 ядра с Intel VT-x / AMD-V RAM: 4 GB /: минимум 8 GB свободно /var/lib: минимум 20 GB свободно -OS: Ubuntu Server 24.04 LTS / Debian-like с apt +OS: Ubuntu Server 26.04 / 24.04 LTS или Debian 13 Network: один проводной интерфейс ``` @@ -149,7 +159,7 @@ CPU: 4+ ядра RAM: 16+ GB Storage: 100+ GB SSD/NVMe под /var/lib/virtuality Network: 1 Gbit/s+ -OS: Ubuntu Server 24.04 LTS +OS: Ubuntu Server 26.04 LTS ``` ARM64 edge nodes: @@ -440,9 +450,12 @@ VIRTUALITY_COOKIE_SECURE=0 # 1 — cookie только по HTTPS (за rev ```bash pip install -r web/requirements.txt -r requirements-dev.txt -make check # pyflakes + shellcheck + pytest +make check # pyflakes + shellcheck + pytest +python3 tests/dev_server.py # панель на http://127.0.0.1:8765 с имитацией virsh, вход: любой пароль ``` +Интерфейс: шаблоны `web/templates` (общий каркас `base.html`, компоненты `_ui.html`), стили `web/static/app.css` (светлая и тёмная темы на CSS-переменных), поведение `web/static/panel.js`. Шрифт Inter и иконки Lucide лежат в `web/static` — панель работает без доступа в интернет. + Код панели лежит в `web/` целиком и устанавливается как есть. Патчить `app.py` во время установки больше не нужно: изменения вносятся прямо в `web/` и покрываются тестами в `tests/`. GitHub Actions запускает проверки на Python 3.11–3.13 и собирает ISO по тегу `v*`. --- diff --git a/docs/IMAGE.md b/docs/IMAGE.md index e4c49c1..7d14b8e 100644 --- a/docs/IMAGE.md +++ b/docs/IMAGE.md @@ -11,7 +11,7 @@ Virtuality можно поставить двумя способами: ## Что внутри образа -Образ собирается из официального **Ubuntu Server 24.04 LTS live-server ISO** (amd64 или arm64) и дополняется: +Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64; поддержка до 2031 года) и дополняется: ```text /autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall) @@ -63,23 +63,24 @@ make iso # amd64 make iso ARCH=arm64 # ARM64-серверы с UEFI ``` -Скрипт сам скачает последний Ubuntu 24.04.x live-server ISO, проверит SHA256 и положит результат в `dist/`: +Скрипт сам скачает последний Ubuntu 26.04.x live-server ISO, проверит SHA256 и положит результат в `dist/`: ```text -dist/virtuality-0.10.0-ubuntu-24.04-amd64.iso -dist/virtuality-0.10.0-ubuntu-24.04-amd64.iso.sha256 +dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso +dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso.sha256 ``` Скачанный ISO Ubuntu кешируется в `.cache/iso`. Уже скачанный образ можно передать явно: ```bash -./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-24.04.5-live-server-amd64.iso +./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-26.04.1-live-server-amd64.iso ``` ### Параметры ```text --arch amd64|arm64 архитектура +--ubuntu 26.04|24.04 база образа (по умолчанию 26.04 LTS) --ref GIT_REF какую версию Virtuality положить в образ (по умолчанию HEAD) --web-port PORT порт web-панели (по умолчанию 8088) --no-auto-update не включать ночное автообновление с GitHub @@ -111,7 +112,7 @@ make iso ISO_ARGS="--unattended --username admin --password-hash '$(openssl pass Linux / macOS: ```bash -sudo dd if=dist/virtuality-0.10.0-ubuntu-24.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync +sudo dd if=dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync ``` Windows: Rufus (режим DD) или balenaEtcher. @@ -119,7 +120,7 @@ Windows: Rufus (режим DD) или balenaEtcher. Проверка целостности: ```bash -cd dist && sha256sum -c virtuality-0.10.0-ubuntu-24.04-amd64.iso.sha256 +cd dist && sha256sum -c virtuality-0.10.0-ubuntu-26.04-amd64.iso.sha256 ``` --- @@ -137,6 +138,6 @@ Workflow `.github/workflows/image.yml` собирает ISO: ## Ограничения -- Целевая ОС образа — Ubuntu Server 24.04 LTS. +- База образа — Ubuntu Server 26.04 LTS (или 24.04 LTS через `--ubuntu 24.04`). - Для первой загрузки нужен интернет: пакеты KVM/libvirt/Cockpit ставятся из репозиториев Ubuntu. Python-зависимости панели уже лежат в образе. - ARM64-образ рассчитан на серверы с UEFI. Raspberry Pi и Orange Pi 5 грузятся иначе — для них используйте их штатный образ Ubuntu Server и `install.sh`. diff --git a/docs/screenshots/create.png b/docs/screenshots/create.png new file mode 100644 index 0000000..cbb36bc Binary files /dev/null and b/docs/screenshots/create.png differ diff --git a/docs/screenshots/dashboard-dark.png b/docs/screenshots/dashboard-dark.png new file mode 100644 index 0000000..5950c53 Binary files /dev/null and b/docs/screenshots/dashboard-dark.png differ diff --git a/docs/screenshots/dashboard-mobile.png b/docs/screenshots/dashboard-mobile.png new file mode 100644 index 0000000..d9a706c Binary files /dev/null and b/docs/screenshots/dashboard-mobile.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..9178074 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/login.png b/docs/screenshots/login.png new file mode 100644 index 0000000..d79b59e Binary files /dev/null and b/docs/screenshots/login.png differ diff --git a/docs/screenshots/network.png b/docs/screenshots/network.png new file mode 100644 index 0000000..cea7fb7 Binary files /dev/null and b/docs/screenshots/network.png differ diff --git a/docs/screenshots/vm.png b/docs/screenshots/vm.png new file mode 100644 index 0000000..ed26561 Binary files /dev/null and b/docs/screenshots/vm.png differ diff --git a/tests/dev_server.py b/tests/dev_server.py new file mode 100644 index 0000000..9df2e62 --- /dev/null +++ b/tests/dev_server.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run the panel locally with fake virsh/systemctl for UI work. + + python3 tests/dev_server.py [port] # login: tester / any password +""" +import json +import sys +import tempfile +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import conftest # noqa: E402 (sets sys.path and env) + +import app # noqa: E402 +import host_profile # noqa: E402 +import network_core # noqa: E402 +import update_core # noqa: E402 + + +def seed(root: Path) -> None: + dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")} + for path in dirs.values(): + path.mkdir(parents=True, exist_ok=True) + app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"] + network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"] + network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json" + network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml" + network_core.NFT_FILE = dirs["nft"] / "virtuality.nft" + network_core.enable_ip_forward = lambda: None + network_core.disable_rp_filter = lambda: None + host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json" + update_core.STATE_DIR, update_core.STATE_FILE, update_core.LOG_FILE = dirs["update"], dirs["update"] / "state.json", dirs["update"] / "update.log" + update_core.SOURCE_DIR = Path(__file__).resolve().parents[1] + for module in (app, network_core, host_profile): + module.run_cmd = conftest.fake_run_cmd + app.auth.verify_password = lambda user, password: True + + for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)): + with (dirs["iso"] / name).open("wb") as handle: + handle.truncate(size * 1024 * 1024) + with (dirs["disk-images"] / "debian-13-genericcloud-amd64.qcow2").open("wb") as handle: + handle.truncate(2 * 1024 * 1024) + network_core.PORT_FORWARDS_FILE.write_text(json.dumps([ + {"id": "f1", "vm_name": "web01", "guest_ip": "192.168.100.51", "external_port_start": 2222, "external_port_end": 2222, "guest_port_start": 22, "guest_port_end": 22, "protocol": "tcp", "note": "SSH"}, + {"id": "f2", "vm_name": "web01", "guest_ip": "192.168.100.51", "external_port_start": 443, "external_port_end": 443, "guest_port_start": 443, "guest_port_end": 443, "protocol": "tcp", "note": "HTTPS"}, + ])) + for status, title, progress in (("success", "Создание VM web01", 100), ("running", "Конвертация debian-13.img", 64), ("error", "Создание VM test", 100)): + op_id = str(uuid.uuid4()) + app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"}) + app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...") + + +if __name__ == "__main__": + import uvicorn + + seed(Path(tempfile.mkdtemp(prefix="virtuality-dev-"))) + uvicorn.run(app.app, host="127.0.0.1", port=int(sys.argv[1]) if len(sys.argv) > 1 else 8765, log_level="warning") diff --git a/tests/screenshots.mjs b/tests/screenshots.mjs new file mode 100644 index 0000000..48dcca1 --- /dev/null +++ b/tests/screenshots.mjs @@ -0,0 +1,38 @@ +// Screenshots of every panel page for design review. +// Usage: node tests/screenshots.mjs [baseUrl] [outDir] [theme] +import { createRequire } from 'node:module'; +const { chromium } = createRequire(import.meta.url)('playwright'); // NODE_PATH=$(npm root -g) for a global install +import { mkdirSync } from 'node:fs'; + +const base = process.argv[2] || 'http://127.0.0.1:8765'; +const out = process.argv[3] || 'screenshots'; +const theme = process.argv[4] || ''; +const pages = [ + ['login', '/login'], ['dashboard', '/'], ['vm', '/vm/web01'], ['create', '/vm/create'], ['help', '/help'], ['operation', '/operations'], + ['iso', '/iso'], ['disks', '/disk-images'], ['network', '/network'], ['operations', '/operations'], + ['logs', '/logs'], ['update', '/update'], ['host', '/host'], ['console', '/vm/web01/console'], +]; +mkdirSync(out, { recursive: true }); +const browser = await chromium.launch(); +for (const [width, suffix] of [[1440, ''], [390, '-mobile']]) { + const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: 1 }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (err) => errors.push(err.message)); + if (theme) await page.addInitScript((t) => localStorage.setItem('virtualityTheme', t), theme); + await page.goto(base + '/login'); + if (suffix === '') await page.screenshot({ path: `${out}/login${suffix}.png`, fullPage: true }); + await page.fill('input[name=password]', 'x'); + await page.click('button[type=submit]'); + await page.waitForURL(base + '/'); + for (const [name, path] of pages.slice(1)) { + if (suffix && !['dashboard', 'vm', 'create'].includes(name)) continue; + await page.goto(base + path); + await page.waitForTimeout(400); + await page.screenshot({ path: `${out}/${name}${suffix}.png`, fullPage: true }); + } + if (errors.length) console.log('JS errors:', errors.join(' | ')); + await context.close(); +} +await browser.close(); +console.log('saved to', out); diff --git a/tests/test_app.py b/tests/test_app.py index 3d47ae2..ba4310e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -4,7 +4,7 @@ import pytest import app -PAGES = ["/", "/host", "/iso", "/disk-images", "/network", "/operations", "/logs", "/update", "/vm/create", "/vm/web01", "/vm/web01/console"] +PAGES = ["/", "/help", "/host", "/iso", "/disk-images", "/network", "/operations", "/logs", "/update", "/vm/create", "/vm/web01", "/vm/web01/console"] def test_healthz_is_public(client): @@ -85,7 +85,38 @@ def test_vm_details_include_autostart(data_dirs): def test_vm_detail_shows_autostart_enabled(logged_in): - assert "autostart: enabled" in logged_in.get("/vm/web01").text + html = logged_in.get("/vm/web01").text + assert 'action="/vm/web01/autostart-disable"' in html + assert 'role="switch" aria-checked="true"' in html + + +def test_dashboard_uses_plain_language(logged_in): + html = logged_in.get("/").text + assert "Работает" in html and "Выключена" in html + assert "2 ядра" in html and "2 ГБ" in html + assert "shut off" not in html + + +def test_vm_create_preselects_image(logged_in, data_dirs): + (data_dirs["iso"] / "debian.iso").write_bytes(b"iso") + html = logged_in.get("/vm/create?iso=debian.iso").text + assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" selected' in html + + +def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch): + image = data_dirs["disk_images"] / "cloud.qcow2" + image.write_bytes(b"qcow") + captured = {} + monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(cmd=cmd)) + monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {}) + form = {"name": "cloud-vm", "memory": 1024, "vcpus": 1, "disk_image_path": str(image), "source_type": "disk_image", "network_mode": "nat"} + response = logged_in.post("/vm/create", data=form, follow_redirects=False) + assert response.status_code == 303, response.text[:300] + assert "--import" in captured["cmd"][-1] + + +def test_help_page(logged_in): + assert "Как создать машину" in logged_in.get("/help").text def test_redirect_message_is_url_encoded(logged_in, monkeypatch): diff --git a/tests/test_core.py b/tests/test_core.py index ca9c0c3..92e7cb5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -65,3 +65,40 @@ def test_versions_manifest_matches_version_file(): version = (root / "VERSION").read_text().strip() manifest = json.loads((root / "updates" / "versions.json").read_text()) assert version in [item["version"] for item in manifest["versions"]] + + +def test_presenters_parse_virsh_tables(): + import presenters + + disks = presenters.parse_domblklist(" Type Device Target Source\n------------------------------------\n file disk vda /var/lib/virtuality/images/web01.qcow2\n file cdrom sda -\n") + assert [d["name"] for d in disks] == ["web01.qcow2", "Пусто"] + assert disks[1]["is_cdrom"] + nics = presenters.parse_domiflist(" Interface Type Source Model MAC\n---\n vnet0 network virtuality-nat virtio 52:54:00:aa:bb:cc\n") + assert nics[0]["mac"] == "52:54:00:aa:bb:cc" + info = presenters.parse_dominfo("State: shut off\nCPU(s): 4\nMax memory: 8388608 KiB\nUsed memory: 8388608 KiB\nAutostart: disable\n") + assert (info["vcpus"], info["memory_mb"], info["autostart"]) == (4, 8192, False) + assert presenters.vm_state("shut off")["label"] == "Выключена" + assert presenters.format_mb(1536) == "1.5 ГБ" + assert presenters.plural(22, "ядро", "ядра", "ядер") == "ядра" + + +def test_update_not_offered_when_local_is_newer(tmp_path, monkeypatch, data_dirs): + import subprocess + + def git(*args, cwd): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + remote, local = tmp_path / "remote", tmp_path / "local" + remote.mkdir() + git("init", "-q", "-b", "main", cwd=remote) + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "base", cwd=remote) + git("clone", "-q", str(remote), str(local), cwd=tmp_path) + (local / "VERSION").write_text("0.10.0\n") + git("add", "VERSION", cwd=local) + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "newer local build", cwd=local) + monkeypatch.setattr(update_core, "SOURCE_DIR", local) + assert update_core.check_updates(fetch=True)["has_update"] is False + + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "upstream", cwd=remote) + git("reset", "-q", "--hard", "origin/main", cwd=local) + assert update_core.check_updates(fetch=True)["has_update"] is True diff --git a/updates/versions.json b/updates/versions.json index c4e090a..5ada224 100644 --- a/updates/versions.json +++ b/updates/versions.json @@ -3,22 +3,26 @@ { "version": "0.10.0", "date": "2026-09-25", - "title": "Система была подготовлена к продакшену и сборке образа", - "summary": "Web-панель больше не собирается патчами во время установки: итоговый код хранится в репозитории и покрыт тестами. Исправлены ошибки создания VM и отображения автозапуска, усилена безопасность входа, настройки ноды сохраняются между обновлениями. Добавлена сборка установочного ISO-образа Virtuality на базе Ubuntu Server 24.04.", + "title": "Новый интерфейс, установочный образ и подготовка к продакшену", + "summary": "Панель полностью перерисована: светлая и тёмная темы, понятный русский язык вместо технических терминов, мастер создания машины из трёх шагов, открытие доступа из интернета в пару кликов. Добавлен установочный ISO-образ Virtuality на базе Ubuntu Server 26.04 LTS. Исправлены ошибки создания VM, автозапуска и обновлений, усилена безопасность.", "features": [ + "Новый дизайн панели: светлая и тёмная темы, адаптивная вёрстка для телефона", + "Главная страница: загрузка процессора, памяти и хранилища, карточки машин с быстрыми действиями", + "Мастер создания машины: готовые конфигурации «Мини», «Стандарт», «Мощная»", + "Страница машины: вкладки, переключатель автозапуска, порядок загрузки кнопками", + "Раздел «Сеть и доступ»: открытие SSH, сайта, RDP и других сервисов из списка", + "Загрузка образов перетаскиванием с прогрессом, скоростью и оставшимся временем", + "Подтверждения опасных действий и уведомления о результате", + "Раздел «Помощь» с инструкциями для частых задач", + "Установочный ISO на базе Ubuntu Server 26.04 LTS: image/build-iso.sh", + "Установщик ноды работает на Ubuntu 24.04, 26.04 и Debian 13", "Исправлено падение создания VM из-за отсутствующего импорта platform", - "На странице VM автозапуск теперь показывается корректно", - "Сообщения об операциях с VM корректно кодируются в адресе страницы", - "Проверка пароля работает на Python 3.13+, где удалены модули crypt и spwd", - "Добавлены ограничение попыток входа, срок жизни сессии, защита от cross-origin POST и заголовки безопасности", - "Добавлен публичный /healthz для мониторинга", + "Исправлено: центр обновлений и автообновление предлагали откат на старую версию", + "Проверка пароля работает на Python 3.13+ без модулей crypt и spwd", + "Ограничение попыток входа, срок жизни сессии, защита от cross-origin POST", "Правила проброса портов восстанавливаются после перезагрузки сервера", - "Порт, пользователь панели и режим автообновления сохраняются в /var/lib/virtuality/config/web.env и больше не сбрасываются при обновлении", - "Обновление из панели больше не прерывается перезапуском virtuality-web", - "Автообновление можно отключить: VIRTUALITY_AUTO_UPDATE=0", - "Python-зависимости закреплены полностью и могут ставиться офлайн", - "Добавлена сборка установочного ISO: image/build-iso.sh", - "Добавлены автотесты и GitHub Actions", + "Настройки ноды сохраняются в /var/lib/virtuality/config/web.env и не сбрасываются обновлением", + "Публичный /healthz для мониторинга, автотесты и GitHub Actions", "Версия обновлена до 0.10.0" ] }, diff --git a/web/app.py b/web/app.py index 2ec82ba..c911878 100644 --- a/web/app.py +++ b/web/app.py @@ -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 diff --git a/web/host_profile.py b/web/host_profile.py index d5dfecf..6804bcb 100644 --- a/web/host_profile.py +++ b/web/host_profile.py @@ -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 diff --git a/web/presenters.py b/web/presenters.py new file mode 100644 index 0000000..89e525e --- /dev/null +++ b/web/presenters.py @@ -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), "") diff --git a/web/static/LICENSE-lucide.txt b/web/static/LICENSE-lucide.txt new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/web/static/LICENSE-lucide.txt @@ -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. diff --git a/web/static/app.css b/web/static/app.css index 6a77bf8..33b98fa 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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; } } diff --git a/web/static/fonts/LICENSE-Inter.txt b/web/static/fonts/LICENSE-Inter.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/web/static/fonts/LICENSE-Inter.txt @@ -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. diff --git a/web/static/fonts/inter-cyrillic.woff2 b/web/static/fonts/inter-cyrillic.woff2 new file mode 100644 index 0000000..d750914 Binary files /dev/null and b/web/static/fonts/inter-cyrillic.woff2 differ diff --git a/web/static/fonts/inter-latin.woff2 b/web/static/fonts/inter-latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/web/static/fonts/inter-latin.woff2 differ diff --git a/web/static/icons.svg b/web/static/icons.svg new file mode 100644 index 0000000..12ffba4 --- /dev/null +++ b/web/static/icons.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/static/panel.js b/web/static/panel.js index 1932601..5581d03 100644 --- a/web/static/panel.js +++ b/web/static/panel.js @@ -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') => ``; + const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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]) => `${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 = '
Тема
'; - 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 = ` -
-
Состояние${escapeHtml(state)}
-
CPU${escapeHtml(cpu)}
-
Тип ОС${escapeHtml(osType)}
-
-
-
${memPct}%
-
-
Память${usedMem ? usedMem.toFixed(1) : '—'} / ${maxMem ? maxMem.toFixed(1) : '—'} GB
-
vCPU capacity${escapeHtml(cpu)} vCPU
-
Автозапуск${escapeHtml(autostartText)}
-
-
`; - } - 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 = '
Диски не найдены в выводе libvirt.
'; - return; - } - box.innerHTML = `
${rows.map((row) => ` -
-
▣
-
${escapeHtml(row.target)}${escapeHtml(row.device)} · ${escapeHtml(row.type)}
${escapeHtml(row.source)}
- disk -
`).join('')}
`; - } - 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 = '
Сетевые интерфейсы не найдены в выводе libvirt.
'; - return; - } - box.innerHTML = `
${rows.map((row) => ` -
-
⇄
-
${escapeHtml(row.iface)}${escapeHtml(row.type)} · ${escapeHtml(row.source)} · ${escapeHtml(row.model)}
${escapeHtml(row.mac)}
- link -
`).join('')}
`; - } - 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')}
${escapeHtml(message)}
`; 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 = `${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(); + }); })(); diff --git a/web/templates/_images_header.html b/web/templates/_images_header.html new file mode 100644 index 0000000..1198b54 --- /dev/null +++ b/web/templates/_images_header.html @@ -0,0 +1,12 @@ +{% from "_ui.html" import icon %} + + diff --git a/web/templates/_ui.html b/web/templates/_ui.html new file mode 100644 index 0000000..ac67028 --- /dev/null +++ b/web/templates/_ui.html @@ -0,0 +1,68 @@ +{% macro icon(name, cls='') -%} + +{%- endmacro %} + +{% macro logo() -%} + +{%- endmacro %} + +{% macro badge(label, tone='neutral', dot=True, cls='', attrs='') -%} +{% if dot %}{% endif %}{{ label }} +{%- endmacro %} + +{% macro vm_badge(state, cls='') -%} +{%- set s = state|vm_state -%} +{{ s.label }} +{%- endmacro %} + +{% macro op_badge(status, cls='') -%} +{%- set s = status|operation_state -%} +{{ s.label }} +{%- endmacro %} + +{% macro meter(pct, tone=None) -%} +
+{%- endmacro %} + +{% macro empty(icon_name, title, text, action_url=None, action_label=None, action_icon='plus') -%} +
+
{{ icon(icon_name, 'icon-xl') }}
+

{{ title }}

+

{{ text }}

+ {% if action_url %}
{{ icon(action_icon) }}{{ action_label }}
{% endif %} +
+{%- 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'} -%} +
+ {{ icon(icon_name or icons.get(tone, 'info')) }} +
{% if title %}{{ title }}{% endif %}{{ text }}
+
+{%- endmacro %} + +{% macro uploader(action, field, accept, title, hint, redirect, button) -%} +
+ + + +
+ + +
+
+{%- endmacro %} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..5364d58 --- /dev/null +++ b/web/templates/base.html @@ -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='') -%} +{{ icon(icon_name) }}{{ label }}{{ extra|safe }} +{%- endmacro -%} + + + + + + + {% block title %}{{ page_title|default('Панель') }}{% endblock %} — Virtuality + + + + + {% block head %}{% endblock %} + + + {%- set notice = update_notice() -%} +
+ + +
+
+ + + +
+ +
+ {% block page_header %}{% endblock %} +
{% block alerts %}{% if error %}{{ alert('danger', error) }}{% endif %}{% endblock %}
+ {% block content %}{% endblock %} +
+
+
+ + +
+
+
{{ icon('triangle-alert', 'icon-lg') }}
+

Подтвердите действие

+
+
+ + +
+
+
+
+ + {% block scripts %}{% endblock %} + + diff --git a/web/templates/console.html b/web/templates/console.html index 84ea592..dba2221 100644 --- a/web/templates/console.html +++ b/web/templates/console.html @@ -1,48 +1,37 @@ +{% from "_ui.html" import icon, logo, vm_badge %} - + - Консоль {{ vm.name }} — {{ app_name }} - + Экран {{ vm.name }} — Virtuality + - -
-
-
- {{ vm.name }} - VNC {{ console.display }}{% if console.port %} · port {{ console.port }}{% endif %} + +
+
+ {{ logo() }} + {{ vm.name }} + {{ vm_badge(vm.info.state) }} +
+
+ Чтобы вернуть курсор, нажмите Ctrl+Alt + {{ icon('settings', 'icon-sm') }}Настройки машины +
+
+ {% if console.url %} + + {% else %} +
+
+
+
{{ icon('monitor', 'icon-xl') }}
+

Экран недоступен

+

{% if not console.has_novnc %}На сервере не установлен компонент noVNC. Переустановите панель: sudo bash scripts/install_web_panel.sh{% else %}Машина выключена или ещё загружается. Запустите её и обновите страницу.{% endif %}

+
К машине
+
-
- {{ 'connected ready' if console.url else 'not ready' }} - VM - Дашборд -
-
- - {% if console.url %} -
- -
- {% else %} -
-
-
-

Консоль пока недоступна

- not ready -
-
-
noVNC path{{ console.novnc_dir }}
-
VNC display{{ console.display }}
-
VNC port{{ console.port or 'not available' }}
-
VM IP{{ vm.ip }}
-
-
- Проверь, что VM запущена, у неё есть VNC display, а пакет novnc установлен. После обновления web-панели установщик должен поставить novnc автоматически. -
-
-
- {% endif %} -
+ + {% endif %} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 536cf42..45ab270 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -1,190 +1,163 @@ - - - - - - {{ app_name }} Panel - - - -
- {% 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 %} -
-
-
-
Обзор
-
{{ system.hostname }} · {{ system.ip }} · {{ system.time }}
-
-
- user: {{ user }} -
-
-
- - {% if update_notice and update_notice.has_update %} -
-
-
Доступно обновление Virtuality
-
{{ update_notice.current_version }} → {{ update_notice.latest_version }}
-
Пропущено версий: {{ update_notice.missing_count }} · новых коммитов: {{ update_notice.commit_count }}
-
- -
- {% endif %} - -
-
-
Uptime
-
{{ system.uptime }}
-
-
-
Load
-
{{ system.load }}
-
-
-
Kernel
-
{{ system.kernel }}
-
-
-
VM
-
{{ vms|length }}
-
-
- -
-
-

Виртуальные машины

- -
- - - - - - {% for vm in vms %} - {% set is_running = 'running' in vm.state %} - - - - - - - - - {% else %} - - {% endfor %} - -
IDNameIPStateAutostartActions
{{ vm.id }}{{ vm.name }}{{ vm.ip|default('—') }}{{ vm.state }} - {% if vm.autostart_enabled %} -
- -
- {% else %} -
- -
- {% endif %} -
-
- ℹДетали - ▣Консоль - {% if is_running %} -
-
-
- {% else %} -
-
- {% endif %} -
-
Виртуальных машин пока нет
-
- -
-
-
-

Сервисы

- systemd -
-
- {% for name, state in services.items() %} -
- {{ name }} - {{ state }} -
- {% endfor %} -
-
- -
-
-

Storage pools

- libvirt -
- - - - - - {% for pool in pools %} - - - - - - {% else %} - - {% endfor %} - -
NameStateAutostart
{{ pool.name }}{{ pool.state }}{{ pool.autostart }}
Storage pools не найдены
-
- - -
- -
-
-
-

Интерфейсы

- ip -br a -
-
{{ network.interfaces }}
-
-
-
-

Маршруты

- ip route -
-
{{ network.routes }}
-
-
-
+{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% set notice = update_notice() %} + {% if notice.has_update %} +
{{ icon('sparkles') }}
Доступна новая версия {{ notice.latest_version }}Обновление займёт пару минут, машины продолжат работать.
Посмотреть
+ {% endif %} + {% if profile.virtualization_warning %} + {{ alert('warning', 'Машины будут работать медленно. Включите аппаратную виртуализацию (Intel VT-x / AMD-V) в настройках BIOS сервера.', 'Аппаратное ускорение недоступно') }} + {% endif %} +{% endblock %} + +{% block content %} +
+
+
Машины{{ icon('monitor', 'icon-sm') }}
+
{{ running }}/ {{ vms|length }} работают
+ {{ meter(((running / vms|length * 100) if vms else 0)|round|int, 'brand') }} +
+
+
Процессор{{ icon('cpu', 'icon-sm') }}
+
{{ host.cpu_pct }}%
+ {{ meter(host.cpu_pct) }} +
{{ host.cpu_count }} {{ plural(host.cpu_count, 'ядро', 'ядра', 'ядер') }}
+
+
+
Память{{ icon('memory-stick', 'icon-sm') }}
+
{{ host.mem_used }}из {{ host.mem_total }}
+ {{ meter(host.mem_pct) }} +
Занято {{ host.mem_pct }}%
+
+
+
Хранилище{{ icon('hard-drive', 'icon-sm') }}
+
{{ host.disk_free }}свободно
+ {{ meter(host.disk_pct) }} +
Всего {{ host.disk_total }}
+
+
+ +{% if vms %} +
+
+

Виртуальные машины

Нажмите на машину, чтобы открыть её настройки

+ {{ icon('plus', 'icon-sm') }}Новая +
+
+ {% for vm in vms %} + {% set s = vm.state|vm_state %} +
+ + {{ icon('monitor') }} + + {{ vm.name }} + {{ vm.ip if vm.ip and vm.ip != '—' else ('IP появится после запуска' if not s.running else 'Получает адрес…') }} + + +
+
Ресурсы
+
{{ vm.vcpus or '—' }} {{ plural(vm.vcpus or 0, 'ядро', 'ядра', 'ядер') }} · {{ vm.memory_mb|format_mb }}
+
+
{{ vm_badge(vm.state) }}
+
+ {% if s.running %} + {{ icon('monitor', 'icon-sm') }}Экран + {% else %} +
+ {% endif %} + +
+
+ {% endfor %} +
+
+{% else %} +
+
+
+ {{ icon('sparkles', 'icon-sm') }}Начало работы +

Создайте первую виртуальную машину

+

Три шага: загрузите установочный образ системы, создайте машину и установите систему прямо в браузере.

+
+
+ +
+{% endif %} + +
+
+

Последние задачи

Создание машин, загрузка и конвертация образов

Все{{ icon('chevron-right', 'icon-sm') }}
+
+ {% for op in operations %} + {% set os = op.status|operation_state %} + + {{ icon('circle-check' if op.status == 'success' else 'circle-x' if op.status == 'error' else 'clock') }} +
{{ op.title }}
{{ op.created_at }} · {{ op.message }}
+ {{ op_badge(op.status) }} +
+ {% else %} +

Задач пока не было. Здесь появится прогресс создания машин и загрузки образов.

+ {% endfor %} +
+
+ +
+
+

Состояние сервера

Службы, от которых зависит работа машин

+ {% if services_ok %}{{ badge('Всё в порядке', 'success') }}{% else %}{{ badge('Требует внимания', 'danger') }}{% endif %} +
+
+ {% for svc in service_rows %} +
+ {{ icon('circle-check' if svc.tone == 'success' else 'triangle-alert' if svc.tone == 'warning' else 'circle-dot' if svc.tone == 'neutral' else 'circle-x') }} +
{{ svc.name }}{{ svc.label }}
+
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/web/templates/disk_images.html b/web/templates/disk_images.html index 220c269..146a16b 100644 --- a/web/templates/disk_images.html +++ b/web/templates/disk_images.html @@ -1,329 +1,44 @@ - - - - - - Диски — {{ app_name }} - - - -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, uploader, empty %} +{% set page_title = 'Готовые системы' %} -
-
-
-
Диски
-
Готовые образы установленных систем: .img / .raw / .qcow2 / .img.xz, а также архивы .zip / .tar.gz / .tgz
-
-
- user: {{ user }} - ← Дашборд -
-
-
+{% block page_header %}{% include "_images_header.html" %}{% endblock %} - {% if error %} -
{{ error }}
- {% endif %} - -
-
-
-

Загрузить образ установленной системы

- .img .raw .qcow2 .img.xz .zip .tar.gz +{% block content %} +
+
+

Готовые системы

Образы уже установленных систем — машина запускается сразу, без установки

+ {% if images %} +
+ {% for image in images %} +
+ {{ icon('hard-drive') }} +
{{ image.name }}
{{ image.size|replace('GB', 'ГБ') }} · формат {{ image.format }} · {{ image.updated }}
+ Создать машину +
+ +
-
- -
Файл ещё не выбран. Разрешены .img, .raw, .qcow2, .img.xz, .zip, .tar.gz и .tgz.
- - - -
-

Архив или .img.xz будет распакован автоматически. Virtuality найдёт внутри .img/.raw/.qcow2 или распакует .img.xz и положит результат в /var/lib/virtuality/disk-images. После загрузки .img/.raw/.img.xz запускается конвертация в qcow2. Установочные .iso загружаются отдельно в разделе ISO.

-
+ {% endfor %} +
+ {% else %} + {{ empty('hard-drive', 'Готовых образов пока нет', 'Подойдут облачные образы Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }} + {% endif %} +
-
-
-

Как использовать

- import disk -
-
-
- Raspberry / Orange Pi images - Загрузи .img, .img.xz или архив с .img, дождись распаковки/конвертации, затем в “Создать VM” выбери режим “Готовый диск”. -
-
- Архивы безопасно распаковываются - Поддерживаются .zip, .tar.gz, .tgz и .img.xz. Из обычных архивов берём только .img/.raw/.qcow2. -
-
-
qemu-img convert -p -f raw -O qcow2 source.img source.qcow2
-
-
- -
-
-

Локальные образы дисков

- {{ images|length }} файлов -
- - - - - - {% for image in images %} - - - - - - - - {% else %} - - {% endfor %} - -
ФайлТипРазмерОбновлёнДействия
{{ image.name }}{{ image.format }}{{ image.size }}{{ image.updated }} - Создать VM -
- -
-
Образов пока нет. Загрузи .img, .raw, .qcow2, .img.xz или архив .zip/.tar.gz/.tgz.
-
-
-
- - - - - + + +{% endblock %} diff --git a/web/templates/help.html b/web/templates/help.html new file mode 100644 index 0000000..ac012a7 --- /dev/null +++ b/web/templates/help.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon %} +{% set page_title = 'Помощь' %} + +{% block page_header %} + +{% endblock %} + +{% block content %} +
+
+
{{ icon('plus') }}

Как создать машину

+
    +
  1. Скачайте установочный образ системы (.iso) на сайте разработчика — например, Ubuntu Server.
  2. +
  3. Откройте Образы ОС и перетащите файл в окно загрузки.
  4. +
  5. Нажмите Создать машину, выберите образ и конфигурацию.
  6. +
  7. Когда задача завершится, нажмите «Открыть экран» и установите систему как на обычный компьютер.
  8. +
+
+ +
+
{{ icon('globe') }}

Как открыть сайт или SSH из интернета

+
    +
  1. Откройте Сеть и доступ.
  2. +
  3. В блоке «Открыть доступ» выберите машину и сервис, например «Сайт (HTTPS)».
  4. +
  5. Нажмите «Открыть доступ». Теперь адрес {{ host_ip }}:порт ведёт в машину.
  6. +
  7. Если не открывается — нажмите «Проверить» рядом с правилом: панель подскажет, в чём проблема.
  8. +
+
+ +
+
{{ icon('monitor') }}

Экран машины

+
    +
  • Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.
  • +
  • Если курсор «застрял» внутри экрана, нажмите Ctrl + Alt.
  • +
  • После установки системы извлеките установочный диск на странице машины → «Диски и загрузка».
  • +
+
+ +
+
{{ icon('power') }}

Выключение и автозапуск

+
    +
  • Выключить — аккуратно завершает работу системы, как кнопка питания.
  • +
  • Выключить принудительно — мгновенно, как выдернуть шнур. Используйте, только если машина зависла.
  • +
  • Автозапуск — машина сама включится после перезагрузки сервера.
  • +
+
+ +
+
{{ icon('cpu') }}

Сколько ресурсов выделять

+
    +
  • Мини (1 ядро, 1 ГБ) — VPN, роутер, небольшие сервисы.
  • +
  • Стандарт (2 ядра, 4 ГБ) — сайт, база данных, Linux-сервер.
  • +
  • Мощная (4 ядра, 8 ГБ) — Windows, 1С, игровые серверы.
  • +
  • Процессор и память можно поменять позже, когда машина выключена.
  • +
+
+ +
+
{{ icon('wrench') }}

Если что-то не работает

+
    +
  • Проверьте раздел Сервер — там видно, чего не хватает.
  • +
  • Посмотрите Задачи — у каждой есть подробный журнал.
  • +
  • Полная диагностика в терминале сервера: sudo vhealth.
  • +
  • Расширенное управление доступно в Cockpit: https://{{ host_ip }}:9090.
  • +
+
+
+{% endblock %} diff --git a/web/templates/host.html b/web/templates/host.html index 0406140..dd66593 100644 --- a/web/templates/host.html +++ b/web/templates/host.html @@ -1,96 +1,56 @@ - - - - - - Профиль хоста — {{ app_name }} - - - -
- {% 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 %} -
-
-
-
Профиль хоста
-
Автоматически выбранная сборка Virtuality
-
-
- user: {{ user }} - ← Дашборд -
-
-
- - {% if profile.virtualization_warning %} -
{{ profile.virtualization_warning }}
- {% endif %} - -
-
-
Profile
-
{{ profile.label }}
-
-
-
Arch
-
{{ profile.arch }}
-
-
-
Virtualization
-
{{ profile.virtualization_mode|default('unknown') }}
-
-
-
Default network
-
{{ profile.recommended_network }}
-
-
- -
-
-
-

Модель устройства

- auto-detected -
-
-
Model{{ profile.model }}
-
VM mode{{ profile.recommended_vm_mode }}
-
Ready{{ 'yes' if profile.ready else 'needs check' }}
-
-
- На физическом сервере для нормальной скорости нужен KVM: включи Intel VT-x / AMD-V в BIOS/UEFI. Если KVM недоступен, Virtuality может использовать QEMU fallback, но VM будут работать медленно. -
-
- -
-
-

Проверки

-
-
-
- {% for check in profile.checks %} - {% set level = check.level|default('ok' if check.ok else 'err') %} -
- - {{ check.name }} - {{ check.hint }} - - {{ 'OK' if level == 'ok' else 'WARN' if level == 'warn' else 'FAIL' }} -
- {% endfor %} -
-
-
- -
-
-

Raw profile

- /var/lib/virtuality/config/host_profile.json -
-
{{ profile_json }}
-
-
+{% block page_header %} + +{% endblock %} + +{% block content %} +
+
+
+
+

Готовность

{{ 'Всё готово для запуска машин' if not problems else 'Есть замечания — ниже написано, что сделать' }}

+ {% if profile.ready and not problems %}{{ badge('Готов', 'success') }}{% elif profile.ready %}{{ badge('Работает с замечаниями', 'warning') }}{% else %}{{ badge('Не готов', 'danger') }}{% endif %} +
+
+ {% 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' %} +
+ {{ icon('circle-check' if tone == 'success' else 'triangle-alert' if tone == 'warning' else 'circle-x') }} +
{{ check.name }}{% if tone != 'success' %}{{ check.hint }}{% endif %}
+
+ {% endfor %} +
+
+
+ + +
+{% endblock %} diff --git a/web/templates/iso.html b/web/templates/iso.html index a671a63..4e0edda 100644 --- a/web/templates/iso.html +++ b/web/templates/iso.html @@ -1,272 +1,51 @@ - - - - - - ISO — {{ app_name }} - - - -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, uploader, empty %} +{% set page_title = 'Образы ОС' %} -
-
-
-
ISO
-
Установочные образы только .iso в /var/lib/virtuality/iso
-
-
- user: {{ user }} - ← Дашборд -
-
-
+{% block page_header %}{% include "_images_header.html" %}{% endblock %} - {% if error %} -
{{ error }}
- {% endif %} - -
-
-
-

Загрузить установочный ISO

- только .iso +{% block content %} +
+
+
+

Установочные образы

Файлы .iso для установки системы с нуля

+
+
+ {% if isos %} +
+ {% for iso in isos %} +
+ {{ icon('disc-3') }} +
{{ iso.name }}
{{ iso.size|replace('MB', 'МБ') }} · загружен {{ iso.updated }}
+ Создать машину +
+ +
-
- -
Файл ещё не выбран. В этом разделе разрешены только установочные образы .iso.
- - - -
-

Файл попадёт в /var/lib/virtuality/iso. Для готовых системных дисков используй раздел Диски: .img / .raw / .qcow2.

-
+ {% endfor %} +
+ {% else %} + {{ empty('disc-3', 'Образов пока нет', 'Скачайте установочный образ нужной системы на сайте разработчика и загрузите его сюда.') }} + {% endif %} +
-
-
-

Действия

- pool -
-
-
- Создать VM -
-
sudo virsh pool-refresh virtuality-iso
-ls -lh /var/lib/virtuality/iso
-
-
- -
-
-

ISO-образы

- {{ isos|length }} файлов -
- - - - - - {% for iso in isos %} - - - - - - - {% else %} - - {% endfor %} - -
ФайлРазмерОбновлёнДействия
{{ iso.name }}{{ iso.size }}{{ iso.updated }} -
- -
-
ISO пока нет. Загрузи первый установочный .iso через форму выше.
-
-
-
- - - - - + + +{% endblock %} diff --git a/web/templates/login.html b/web/templates/login.html index 3b7841a..144c27e 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -1,43 +1,72 @@ +{% from "_ui.html" import icon, logo, alert %} - + - {{ app_name }} Login + + Вход — Virtuality - - + + - -
-
-
Virtuality
-

Вход в панель управления виртуализацией

- - {% if not configured %} -
Панель ещё не настроена. Повтори установку веб-панели.
- {% endif %} - - {% if error %} -
{{ error }}
- {% endif %} - -
- - - -
- -

Используется пароль системного пользователя Linux. Пароль не хранится в Virtuality.

+ +
+
+ +
+

Ваши серверы.
Под полным контролем.

+

Создавайте виртуальные машины, открывайте к ним доступ из интернета и следите за состоянием сервера — всё в одном окне, без командной строки.

+
+
{{ icon('zap') }}Новая машина за пару минут
+
{{ icon('monitor') }}Экран машины прямо в браузере
+
{{ icon('shield-check') }}Работает на вашем железе, данные у вас
+
+
+
Virtuality {{ app_version }} · KVM · QEMU · libvirt
-
+ +
+
+ +
+

Вход в панель

+

Используйте имя и пароль пользователя этого сервера.

+
+ + {% if not configured %} + {{ alert('warning', 'Панель ещё не настроена. Запустите установку web-панели повторно.') }} + {% endif %} + {% if error %} + {{ alert('danger', error) }} + {% endif %} + +
+ + +
+
+ + +
+ +

{{ icon('lock', 'icon-sm') }} Пароль проверяется системой сервера и нигде не сохраняется.

+
+
+
+ diff --git a/web/templates/logs.html b/web/templates/logs.html index 287bcfa..02f6a23 100644 --- a/web/templates/logs.html +++ b/web/templates/logs.html @@ -1,92 +1,34 @@ - - - - - - Журналы — {{ app_name }} - - - -
- +{% extends "base.html" %} +{% from "_ui.html" import icon %} +{% set page_title = 'Журналы' %} -
-
-
-
Журналы
-
systemd, libvirt, update, install и операции Virtuality
-
-
- user: {{ user }} - ← Дашборд -
-
-
- -
- {% for key, source in sources.items() %} - -
Источник
-
{{ source.title }}
-
- {% endfor %} -
- -
-
-
-

{{ selected.title }}

-
{{ selected.cmd }}
-
-
- 120 - 300 - 1000 - -
-
-
{{ selected.content }}
-
-
+{% block page_header %} + +{% endblock %} - - - - +{% block content %} + +
+
+

{{ selected.title }}

{{ selected.cmd }}

+
+ +
+ {% for n in (120, 300, 1000) %}{{ n }}{% endfor %} +
+
+
+
+
{{ selected.content }}
+
+
+{% endblock %} diff --git a/web/templates/network.html b/web/templates/network.html index 9ec1a42..0e3fb71 100644 --- a/web/templates/network.html +++ b/web/templates/network.html @@ -1,271 +1,185 @@ - - - - - - Сеть — {{ app_name }} - - - -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert, empty %} +{% set page_title = 'Сеть и доступ' %} -
-
-
-
Сеть
-
NAT Router для VPS, проброс одиночных портов и диапазонов к VM
-
-
- user: {{ user }} - ← Дашборд -
-
-
- - {% if error %} -
{{ error }}
- {% endif %} - -
-
-
NAT network
-
{{ ctx.nat.name }}
- {{ 'ready' if ctx.nat.exists else 'not created' }} -
-
-
Subnet
-
{{ ctx.nat.subnet }}
-
-
-
External interface
-
{{ ctx.external_interface }}
-
-
-
IPv4 forward
-
{{ ctx.ip_forward }}
-
-
- -
-
-
-

Virtuality NAT Router

- {{ ctx.nat.bridge }} -
-
-
Gateway{{ ctx.nat.gateway }}
-
DHCP{{ ctx.nat.dhcp }}
-
Bridge{{ ctx.nat.bridge }}
-
-
- -
-
{{ ctx.nat.info }}
-
- -
-
-

DHCP leases

- virsh net-dhcp-leases -
-
{{ ctx.nat.leases }}
-
IP для проброса можно оставить auto или ввести вручную, если DHCP leases пустые, у VM статический IP, bridge-сеть или нет qemu-guest-agent.
-
-
- -
-
-
-

Добавить проброс порта

- DNAT auto/manual IP · ranges -
-
- - - - - - -
Можно указать одиночный порт или диапазон. Если авто-IP не определяется, впиши IP VM вручную. Для VoIP/RTP обычно: внешний 10000-20000 → внутренний 10000-20000, протокол udp.
- -
-
- -
-
-

Диагностика публичного доступа

- VM → Internet -
-
- -
- - -
- - -
-
-
- - {% if diagnostics %} -
-
-

Результат диагностики

- {{ diagnostics.vm_name }} · {{ diagnostics.protocol }} {{ diagnostics.external_port }} → {{ diagnostics.guest_port }} -
-
{{ diagnostics.verdict }}
-
-
VM IP{{ diagnostics.vm_ip or 'not found' }}
-
External interface{{ diagnostics.external_interface }}
-
Mapping{{ diagnostics.external_port }} → {{ diagnostics.guest_port }}
-
- - - - {% for check in diagnostics.checks %} - - - - - - {% endfor %} - -
ПроверкаСтатусДетали
{{ check.name }}{{ 'OK' if check.ok else 'FAIL' }}{{ check.detail }}
-
-
Проверить входящий трафик
{{ diagnostics.commands.watch_external }}
-
Проверить трафик к VM
{{ diagnostics.commands.watch_internal }}
-
-
- {% endif %} - -
-
-

Пробросы портов

-
-
- - - - - - {% for item in ctx.forwards %} - - - - - - - - - {% else %} - - {% endfor %} - -
ПробросVMВнутренний адресПротоколОписаниеДействия
- {{ item.external_port_label|default(item.external_port) }} → {{ item.guest_port_label|default(item.guest_port) }} - {% if item.is_range %}range{% endif %} - {{ item.vm_name }}{{ item.guest_ip }}{{ item.protocol }}{{ item.note }} -
-
- - - - - -
-
-
-
Пробросов пока нет
-
- -
-
-

nftables preview

- /etc/virtuality/nftables/virtuality.nft -
-
{{ ctx.nft_rules }}
-
-
+{% block page_header %} + +{% endblock %} + +{% block content %} +{% if diagnostics %} + {% set failed = diagnostics.checks|rejectattr('ok')|list %} +
+
+

Проверка доступа: {{ diagnostics.vm_name }}, порт {{ diagnostics.external_port }}

{{ diagnostics.protocol|upper }} {{ diagnostics.external_port }} → {{ diagnostics.vm_ip or 'IP не найден' }}:{{ diagnostics.guest_port }}

+ {% if failed %}{{ badge('Есть проблемы: ' ~ failed|length, 'warning') }}{% else %}{{ badge('Всё в порядке', 'success') }}{% endif %} +
+
+ {{ alert('warning' if failed else 'success', diagnostics.verdict) }} +
+
+ {% for check in diagnostics.checks %} +
{{ icon('circle-check' if check.ok else 'circle-x') }}
{{ check.name }}{{ check.detail|truncate(160) }}
+ {% endfor %} +
+ +
+{% endif %} + +
+
+
+

Доступ из интернета

Порты сервера, которые ведут внутрь машин

+ {% if ctx.forwards %}
{% endif %} +
+ {% if ctx.forwards %} +
+ {% for item in ctx.forwards %} + {% set service = item.note or (item.guest_port|port_service) or ('Порт ' ~ item.guest_port_label) %} +
+ {{ icon('globe') }} +
+
{{ service }}
+
Сервер : {{ item.external_port_label }} → {{ item.vm_name }} : {{ item.guest_port_label }} · {{ item.protocol|upper }}{% if item.is_range %} · диапазон{% endif %}
+
+
+ + +
+
+ +
+
+ {% endfor %} +
+ {% else %} + {{ empty('shield-check', 'Всё закрыто', 'Машины выходят в интернет, но снаружи к ним подключиться нельзя. Откройте нужный порт в форме справа.') }} + {% endif %} +
+ +
+

Открыть доступ

Сделайте сервис машины доступным снаружи

+ {% if vms %} +
+
+
+ + +
+
+ + +
+
+
+
+
+
+ +
+ + +
+
+
+ {{ icon('wrench', 'icon-sm') }}Дополнительно{{ icon('chevron-down', 'icon-sm') }} +
+
«auto» — определить автоматически
+
+
+
+
+ +
+ {% else %} + + {% endif %} +
+
+ +
+
+
+

Внутренняя сеть машин

Через неё машины выходят в интернет

+ {% if ctx.nat.exists %}{{ badge('Работает', 'success') }}{% else %}{{ badge('Не создана', 'warning') }}{% endif %} +
+
+
+
Адреса машин{{ ctx.nat.subnet }}
+
Шлюз{{ ctx.nat.gateway }}
+
Выход в интернет{{ ctx.external_interface }}
+
Маршрутизация{{ 'Включена' if ctx.ip_forward == 'enabled' else 'Выключена' }}
+
+
+
+
+ +
+

Адреса машин

Если адрес не определился сам, укажите его вручную

+
+ {% for vm in vms %} +
+ {{ icon('monitor') }} +
{{ vm.name }}
{{ vm.ip if vm.ip and vm.ip != '—' else 'адрес не определён' }}{% if vm.manual_ip %} · задан вручную{% endif %}
+ + + +
+ {% else %} +
Машин пока нет
+ {% endfor %} +
+
+
+ +
+ {{ icon('terminal', 'icon-sm') }}Для специалистов: DHCP и правила nftables{{ icon('chevron-down', 'icon-sm') }} +
+
virsh net-dhcp-leases {{ ctx.nat.name }}
{{ ctx.nat.leases or '—' }}
+
/etc/virtuality/nftables/virtuality.nft
{{ ctx.nft_rules }}
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/operation_detail.html b/web/templates/operation_detail.html index 71b7c13..784430b 100644 --- a/web/templates/operation_detail.html +++ b/web/templates/operation_detail.html @@ -1,170 +1,63 @@ - - - - - - {{ operation.title }} — {{ app_name }} - - - -
-
-
-
{{ operation.title }}
-
Фоновая операция Virtuality
-
-
- user: {{ user }} - Операции - ← Дашборд -
-
-
+{% extends "base.html" %} +{% from "_ui.html" import icon, op_badge %} +{% set page_title = operation.title %} +{% set done = operation.status in ('success', 'error') %} -
-
-
-

Состояние

- {{ operation.status }} -
- -
-
-
Прогресс
-
-
{{ operation.progress }}%
-
-
-
Сообщение
-
{{ operation.message }}
-
-
- -
-
VM{{ operation.vm_name }}
-
RAM{{ operation.memory }} MB
-
CPU{{ operation.vcpus }}
-
Disk{{ operation.disk_size }} GB
-
Bridge{{ operation.bridge }}
-
Created{{ operation.created_at }}
-
-
- -
-
-

Команда

- virt-install -
-
{{ operation.cmd }}
-
-
- -
-
-

Журнал virt-install

- live -
-
{{ operation.log_tail }}
-
- - +{% block page_header %} + +{% endblock %} + +{% block content %} +
+
+ + +
+
Журнал выполнения
+
{{ operation.log_tail or 'Ожидаем вывод…' }}
+
+
+ + +
+{% endblock %} diff --git a/web/templates/operations.html b/web/templates/operations.html index f7f77e0..cc33a8a 100644 --- a/web/templates/operations.html +++ b/web/templates/operations.html @@ -1,56 +1,38 @@ - - - - - - Операции — {{ app_name }} - - - -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, op_badge, empty %} +{% set page_title = 'Задачи' %} -
-
-
-
Операции
-
Журнал фоновых действий Virtuality
-
-
- user: {{ user }} - ← Дашборд -
-
-
- -
-
-

Последние операции

- /var/log/virtuality/operations -
- - - - - - {% for operation in operations %} - - - - - - - {% else %} - - {% endfor %} - -
ОперацияСтатусСозданаДействие
-
{{ operation.title }}
-
{{ operation.message }}
-
{{ operation.status }}{{ operation.created_at }}Открыть
Журнал операций пуст
-
-
+{% block page_header %} + +{% endblock %} + +{% block content %} +
+ {% if operations %} +
+ + + + {% for op in operations %} + {% set os = op.status|operation_state %} + + + + + + + + {% endfor %} + +
ЗадачаСтатусПрогрессНачата
{{ op.title }}
{{ op.message }}
{{ op_badge(op.status) }}
{{ op.created_at }}
+
+ {% else %} + {{ empty('list-checks', 'Задач пока нет', 'Когда вы создадите машину или загрузите образ, здесь появится ход выполнения.', '/vm/create', 'Создать машину') }} + {% endif %} +
+{% endblock %} diff --git a/web/templates/update.html b/web/templates/update.html index d7f8e59..582e198 100644 --- a/web/templates/update.html +++ b/web/templates/update.html @@ -1,199 +1,79 @@ - - - - - - Обновления — {{ app_name }} - - - -
- +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert %} +{% set page_title = 'Обновления' %} -
-
-
-
Обновления
-
GitHub update center: changelog, проверка и установка
-
-
- user: {{ user }} - ← Дашборд -
-
-
+{% block page_header %} + +{% endblock %} - {% if error %} -
{{ error }}
- {% endif %} - -
-
-
Текущая версия
-
{{ info.current_version }}
-
-
-
Новая версия
-
{{ info.latest_version }}
-
-
-
Статус
-
{{ 'Есть обновление' if info.has_update else 'Актуально' }}
- {{ 'update' if info.has_update else 'ok' }} -
-
-
Последняя проверка
-
{{ info.checked_at }}
-
-
- -
-
-
-

Проверка GitHub

- {{ info.remote }}/{{ info.branch }} -
-
-
Source{{ info.source_dir }}
-
Local commit{{ info.current_commit[:12] if info.current_commit else 'unknown' }}
-
Remote commit{{ info.latest_commit[:12] if info.latest_commit else 'unknown' }}
-
- {% if not info.fetch_ok %} -
GitHub fetch вернул ошибку: {{ info.fetch_error }}
- {% endif %} -
+{% block content %} +
+
+
+
+
+ {{ icon('sparkles' if info.has_update else 'shield-check', 'icon-xl') }} +
{% if info.has_update %} -
- -
+

Доступна версия {{ info.latest_version }}

+

У вас установлена {{ info.current_version }}. {% if info.missing_versions %}Пропущено обновлений: {{ info.missing_versions|length }}.{% endif %}

{% else %} -
- -
+

Установлена последняя версия

+

Virtuality {{ info.current_version }} · проверено {{ info.checked_at }}

{% endif %}
-
+
+
+ {% if info.has_update %} +
+ +
+ {% else %} +
+ {% endif %} +
+
+
-
-
-

Пропущенные версии и функции

- {{ info.missing_versions|length }} версий -
-
- {% for release in info.missing_versions %} -
-
-

{{ release.version }} · {{ release.title }}

- {{ release.date }} -
-

{{ release.summary }}

-
    - {% for feature in release.features %} -
  • {{ feature }}
  • - {% endfor %} -
-
- {% else %} -
Нет пропущенных версий. Система на свежем коде.
- {% endfor %} -
-
-
+ + {% if not info.fetch_ok %}{{ alert('warning', 'Не удалось связаться с GitHub: ' ~ (info.fetch_error or 'нет ответа') ~ '. Проверьте доступ сервера в интернет.', 'Проверка не удалась') }}{% endif %} -
-
-
-

Новые коммиты

- {{ info.commits|length }} +
+

Что нового

{{ 'Изменения, которые вы получите' if info.missing_versions else 'Последние изменения' }}

+ {% set releases = info.missing_versions or ([current_release] if current_release else []) %} + {% if releases %} + {% for release in releases|reverse %} +
+
{{ release.version }}

{{ release.title }}

{{ release.date }}
+

{{ release.summary }}

+
    {% for feature in release.features %}
  • {{ feature }}
  • {% endfor %}
-
- {% for commit in info.commits %} -
- {{ commit.short }}{{ commit.subject }} · {{ commit.date }} -
- {% else %} -
Новых коммитов нет.
- {% endfor %} -
-
- -
-
-

Лог обновления

- live -
-
{{ info.log_tail or 'Лог пока пуст.' }}
-
-
-
+ {% endfor %} + {% else %} +
Новых изменений нет — всё уже установлено.
+ {% endif %} +
- - - - + + +{% endblock %} diff --git a/web/templates/vm_create.html b/web/templates/vm_create.html index 28b4da1..cb89a2c 100644 --- a/web/templates/vm_create.html +++ b/web/templates/vm_create.html @@ -1,203 +1,171 @@ - - - - - - Создать VM — {{ app_name }} - - - - {% set disk_images_available = disk_images is defined and disk_images %} - {% set disk_feature_available = disk_images is defined %} -
- {% 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 %} -
-
-
-
Создать VM
-
Новая виртуальная машина через libvirt / virt-install
+{% block page_header %} + +{% endblock %} + +{% block content %} +
+
+
+
1

Название и система

Как назовём машину и что на неё установить

+
+
+ + + Латинские буквы, цифры, точка, дефис. От 2 до 63 символов.
-
- user: {{ user }} - ← Дашборд - + +
+ +
-
- {% if error %} -
{{ error }}
- {% endif %} +
+ + {% if isos %} + + Нужен другой? Загрузите образ. + {% endif %} +
+
+ + {% if disk_images %} + + Образ будет скопирован — оригинал останется для других машин. + {% endif %} +
+ + +
+ -
-
-
-

Параметры

- VM wizard -
- -
-
- + +
+
- - - - + + +{% endblock %} diff --git a/web/templates/vm_detail.html b/web/templates/vm_detail.html index dbc6a02..7764bd1 100644 --- a/web/templates/vm_detail.html +++ b/web/templates/vm_detail.html @@ -1,389 +1,237 @@ - - - - - - {{ vm.name }} — {{ app_name }} - - - - -
- {% 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') %} -
-
-
-
{{ vm.name }}
-
Детали виртуальной машины
+{% block page_header %} + +{% 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 %} + + +
+
+
+

Сводка

+
+
+
Состояние{{ vm_badge(vm.info.state or s.raw) }}
+
IP-адрес{% if has_ip %}{{ vm.ip }} {% else %}{{ 'Определяется…' if s.running else 'Нет — машина выключена' }}{% endif %}
+
Процессор{{ vm.info.vcpus }} {{ plural(vm.info.vcpus, 'ядро', 'ядра', 'ядер') }}
+
Память{{ vm.info.memory_mb|format_mb }}
+
Архитектура{{ rs.arch|default('—') }}
+
Сеть{{ (vm.interface_list[0].source if vm.interface_list else '—')|replace('virtuality-nat', 'Автоматически (NAT)') }}
-
- user: {{ user }} - {{ 'running' if is_running else 'shut off' }} - autostart: {{ autostart_label }} - Консоль ↗ - ← Дашборд -
+
+
+ +
+

Как подключиться

Выберите удобный способ

+
+
+ {{ icon('monitor') }} +
Экран в браузере
Как монитор, подключённый к машине. Подходит для установки системы.
+ {% if s.running %}Открыть{{ icon('external-link', 'icon-sm') }}{% else %}Запустите машину{% endif %}
-
- - {% if resource_message %}
{{ resource_message }}
{% endif %} - {% if resource_error %}
{{ resource_error }}
{% endif %} - {% if iso_message %}
{{ iso_message }}
{% endif %} - {% if iso_error %}
{{ iso_error }}
{% endif %} - {% if boot_message %}
{{ boot_message }}
{% endif %} - {% if boot_error %}
{{ boot_error }}
{% endif %} - -
-
-
-

Управление

- actions -
-
-
-
Основные действия
-
- ▣Консоль - {% if is_running %} -
-
- {% else %} -
- {% endif %} -
-
-
-
Автозапуск
-
- {% if is_autostart %} -
- -
- {% else %} -
- -
- {% endif %} -
-

Автозапуск поднимает VM вместе с libvirt после перезагрузки хоста.

-
-
-
Опасные действия
-
- {% if is_running %} -
- {% endif %} - {% if not is_running %} -
- {% else %} - Удаление доступно после выключения VM. - {% endif %} -
-
-
-
- -
-
-

CPU / RAM / Архитектура

- resources -
-
-
- - -
- - -
- {% if is_running %} -

CPU/RAM/архитектуру можно менять только при выключенной VM.

- {% else %} -

Изменения применяются через XML VM и virsh define.

- {% endif %} -
- -
-
-

Порядок загрузки

- boot -
-
- -
-
☰
ISO / CD-ROMУстановщик или rescue-образ
-
☰
ДискОсновной диск VM
-
☰
Сеть / PXEЗагрузка по сети
-
- -
-

Изменение работает после перезапуска VM.

-
- -
-
-

ISO-привод

- cdrom -
- {% if current_iso %} -
Сейчас подключен ISO: {{ current_iso }}
- {% else %} -
ISO сейчас не подключён.
- {% endif %} -
- - -
-
- -
- {% if not isos %} -
ISO-образов пока нет. Сначала загрузи .iso в разделе ISO.
- {% endif %} -
-
- -
-
-
-

Информация

- overview -
-
-
- Технический вывод dominfo -
{{ vm.dominfo }}
-
-
- -
-
-

Диски

- storage -
-
-
- Технический вывод domblklist -
{{ vm.disks }}
-
-
- -
-
-

Сеть

- network -
-
-
- Технический вывод domiflist -
{{ vm.interfaces }}
-
-
-
- -
-
-

Консоль

- noVNC ↗ +
+ {{ icon('terminal') }} +
SSH с этого сервера
{% if has_ip %}ssh пользователь@{{ vm.ip }}{% else %}Станет доступно, когда машина получит IP-адрес{% endif %}
-
-
VNC display{{ vm.vnc }}
-
Host{{ host_ip }}
-
Web console/vm/{{ vm.name }}/console
+
+ {{ icon('globe') }} +
Доступ из интернета
Откройте порт машины наружу: сайт, SSH, удалённый рабочий стол
+ Настроить
-

Консоль открывается в новой вкладке и занимает всё окно браузера. VNC наружу открывать не нужно.

-
-
+
+
- - - +
+
+
+
+
АвтозапускЗапускать машину вместе с сервером
+ +
+
+
- - - - +
+ +
МБ
+ 1024 МБ = 1 ГБ +
+
+
+ {{ icon('wrench', 'icon-sm') }}Архитектура процессора{{ icon('chevron-down', 'icon-sm') }} +
+ + Меняйте, только если точно знаете, зачем: система внутри может перестать загружаться. +
+
+
+ + + + + + + + +{% endblock %}