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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 07:01:41 +00:00
родитель c4cebd916b
Коммит 4098c270a4
41 изменённых файлов: 3160 добавлений и 2939 удалений
+58
Просмотреть файл
@@ -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")
+38
Просмотреть файл
@@ -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);
+33 -2
Просмотреть файл
@@ -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):
+37
Просмотреть файл
@@ -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