Интерфейс панели был полностью перерисован
Новый дизайн в стиле коммерческих продуктов, рассчитанный на обычного пользователя: - светлая и тёмная темы (следует системной), шрифт 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
Этот коммит содержится в:
@@ -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")
|
||||
@@ -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);
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Ссылка в новой задаче
Block a user