Virtuality 0.10.0: новый интерфейс, установочный ISO на Ubuntu 26.04 LTS, подготовка к продакшену (#1)

Virtuality 0.10.0: новый интерфейс, установочный ISO на Ubuntu 26.04 LTS, подготовка к продакшену
Этот коммит содержится в:
Виктор
2026-09-25 16:16:46 +09:00
коммит произвёл GitHub
родитель 423c00b96a f20782c087
Коммит 873b32c517
98 изменённых файлов: 6059 добавлений и 7843 удалений
+40
Просмотреть файл
@@ -0,0 +1,40 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Tests (Python ${{ matrix.python }})
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
# 3.12: Ubuntu 24.04, 3.13: Debian 13, 3.14: Ubuntu 26.04 (no crypt/spwd modules).
python: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: web/requirements.txt
- run: pip install -r web/requirements.txt -r requirements-dev.txt
- run: make lint-python test
shell:
name: Shell scripts
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: make lint-shell
+64
Просмотреть файл
@@ -0,0 +1,64 @@
name: Build ISO image
on:
push:
tags: ["v*"]
pull_request:
paths:
- "image/**"
- "scripts/virtuality_firstboot.sh"
- "web/requirements.txt"
- ".github/workflows/image.yml"
workflow_dispatch:
inputs:
arch:
description: "Architecture"
type: choice
options: [amd64, arm64, both]
default: amd64
permissions:
contents: read
concurrency:
group: image-${{ github.ref }}
cancel-in-progress: true
jobs:
iso:
name: ISO ${{ matrix.arch }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
arch: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: sudo apt-get update && sudo apt-get install -y xorriso python3-yaml
- uses: actions/cache@v4
with:
path: .cache/iso
key: ubuntu-iso-${{ matrix.arch }}-${{ hashFiles('image/build-iso.sh') }}
restore-keys: ubuntu-iso-${{ matrix.arch }}-
- name: Free disk space
run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL && df -h /
- run: make iso ARCH=${{ matrix.arch }}
- name: Check ISO boot records
run: |
iso=$(ls dist/*.iso)
xorriso -indev "$iso" -report_el_torito plain 2>/dev/null | tee /dev/stderr | grep -q "UEFI"
xorriso -osirrox on -indev "$iso" -extract /autoinstall.yaml autoinstall.yaml -extract /boot/grub/grub.cfg grub.cfg
python3 -c "import yaml; d = yaml.safe_load(open('autoinstall.yaml')); assert d['autoinstall']['version'] == 1"
grep -q "Install Virtuality" grub.cfg
# Ubuntu server ISOs are larger than the 2 GB GitHub release asset limit,
# so images are published as workflow artifacts.
- uses: actions/upload-artifact@v4
with:
name: virtuality-iso-${{ matrix.arch }}
path: |
dist/*.iso
dist/*.sha256
compression-level: 0
retention-days: 30
+3
Просмотреть файл
@@ -91,3 +91,6 @@ composer.lock
# System files
*.pid
*.sock
# Offline Python wheels bundled into system images
wheels/
+34
Просмотреть файл
@@ -0,0 +1,34 @@
PYTHON ?= python3
ARCH ?= amd64
ISO_ARGS ?=
SHELL_SCRIPTS := $(wildcard *.sh scripts/*.sh image/*.sh)
.PHONY: help test lint lint-python lint-shell check iso clean
help:
@echo "make test - run the web panel test suite"
@echo "make lint - pyflakes + shellcheck"
@echo "make check - lint + test"
@echo "make iso - build the installer ISO (ARCH=amd64|arm64, ISO_ARGS='--unattended ...')"
@echo "make clean - remove build output"
test:
$(PYTHON) -m pytest -q
lint-python:
$(PYTHON) -m pyflakes web tests
lint-shell:
@for f in $(SHELL_SCRIPTS); do bash -n "$$f" || exit 1; done
shellcheck -S error $(SHELL_SCRIPTS)
lint: lint-python lint-shell
check: lint test
iso:
./image/build-iso.sh --arch $(ARCH) $(ISO_ARGS)
clean:
rm -rf dist .pytest_cache
find . -name __pycache__ -type d -prune -exec rm -rf {} +
+73 -6
Просмотреть файл
@@ -4,7 +4,7 @@
Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией.
> Текущий статус: ранняя стадия разработки. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding.
> Текущий статус: версия 0.10 — код панели покрыт тестами, есть установочный ISO-образ. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding.
---
@@ -33,6 +33,29 @@ 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 26.04 LTS: установщик спрашивает только сеть, диск и пользователя, а при первой загрузке нода настраивается сама.
```bash
make iso # dist/virtuality-<версия>-ubuntu-26.04-amd64.iso
make iso ARCH=arm64
```
Подробно: [`docs/IMAGE.md`](docs/IMAGE.md).
---
## Быстрая установка одной командой
@@ -125,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: один проводной интерфейс
```
@@ -136,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:
@@ -186,6 +209,8 @@ curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/instal
/var/lib/virtuality/backups # backups
/var/log/virtuality # логи установки и диагностики
/var/log/virtuality/operations # JSON/log фоновых операций
/var/lib/virtuality/config/web.env # настройки ноды: порт, пользователь, автообновление
/var/lib/virtuality/disk-images # загруженные образы дисков
/etc/virtuality/nftables/virtuality.nft # nftables-правила Virtuality
```
@@ -375,6 +400,8 @@ sudo virsh pool-refresh virtuality-iso
## Обновление
Из web-панели: раздел `/update`. Вручную:
```bash
cd /opt/virtuality/source
sudo git pull
@@ -382,6 +409,8 @@ sudo bash scripts/install_web_panel.sh
sudo systemctl restart virtuality-web
```
Порт панели, пользователь и режим автообновления сохраняются в `/var/lib/virtuality/config/web.env` и не сбрасываются при обновлениях.
Полная повторная установка компонентов ноды:
```bash
@@ -392,6 +421,45 @@ sudo bash scripts/install_web_panel.sh
---
## Продакшен-настройки
Настройки ноды — `/var/lib/virtuality/config/web.env`:
```text
VIRTUALITY_WEB_PORT=8088 # порт панели
VIRTUALITY_WEB_HOST=0.0.0.0 # 127.0.0.1, если панель стоит за reverse proxy
VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа
VIRTUALITY_AUTO_UPDATE=1 # 0 — отключить ночное автообновление с GitHub
VIRTUALITY_COOKIE_SECURE=0 # 1 — cookie только по HTTPS (за reverse proxy с TLS)
```
После изменения: `cd /opt/virtuality/source && sudo bash scripts/install_web_panel.sh`. Любой параметр можно передать и переменной окружения при установке, например `VIRTUALITY_AUTO_UPDATE=0`.
Рекомендации:
- для продакшена отключите автообновление (`VIRTUALITY_AUTO_UPDATE=0`) и обновляйтесь вручную через `/update` после проверки версии;
- открывайте панель наружу только через HTTPS reverse proxy (nginx/Caddy) с `VIRTUALITY_WEB_HOST=127.0.0.1` и `VIRTUALITY_COOKIE_SECURE=1`;
- reverse proxy должен передавать исходный `Host` (nginx: `proxy_set_header Host $host;`, для noVNC-консоли ещё `Upgrade`/`Connection`), иначе панель отклонит POST-запросы как cross-origin;
- мониторинг: `GET /healthz` без авторизации возвращает `{"ok": true, "version": "..."}`;
- после 5 неверных паролей вход с этого адреса блокируется на 5 минут; сессия живёт 12 часов;
- правила проброса портов восстанавливаются после перезагрузки сервисом `virtuality-network.service`.
---
## Разработка
```bash
pip install -r web/requirements.txt -r requirements-dev.txt
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*`.
---
## Полезные команды
```bash
@@ -416,14 +484,13 @@ journalctl -u virtuality-web -f
- ARM64 cloud-image templates;
- cloud-init для быстрых VM;
- web-console/noVNC;
- управление storage pools;
- backup/snapshot manager;
- сетевой менеджер bridge/VLAN;
- роли и права пользователей;
- журнал событий;
- автообновление;
- кластеризация.
- кластеризация;
- готовые образы для Raspberry Pi / Orange Pi 5.
---
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.9.6
0.10.0
+9 -289
Просмотреть файл
@@ -1,296 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
# ==========================================================
# Virtuality One-command Bootstrap Installer
# Public install entrypoint for clean Ubuntu/Debian servers.
# ==========================================================
# Legacy entrypoint kept for old documentation links.
# All installation logic lives in install.sh.
REPO_URL="${VIRTUALITY_REPO_URL:-https://github.com/viktor138irk/virtuality.git}"
DEFAULT_INSTALL_USER="${SUDO_USER:-root}"
INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}"
PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}"
PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}"
RUN_BRIDGE="${VIRTUALITY_SETUP_BRIDGE:-0}"
BRIDGE_IFACE="${VIRTUALITY_BRIDGE_IFACE:-}"
RUN_TEST_VM="${VIRTUALITY_CREATE_TEST_VM:-0}"
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
LOG_DIR="/var/log/virtuality"
LOG_FILE="${LOG_DIR}/bootstrap_$(date +%Y%m%d_%H%M%S).log"
MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}"
MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}"
MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
INSTALL_URL="${VIRTUALITY_INSTALL_URL:-https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)"
ESC="\033"
RESET="${ESC}[0m"
BOLD="${ESC}[1m"
GREEN="${ESC}[32m"
YELLOW="${ESC}[33m"
RED="${ESC}[31m"
CYAN="${ESC}[36m"
BLUE="${ESC}[34m"
GRAY="${ESC}[90m"
mkdir -p "$LOG_DIR"
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; }
ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; }
warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; }
fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Bootstrap остановлен.${RESET} Лог: ${LOG_FILE}"; exit 1; }
step() { echo; echo -e "${BLUE}${BOLD}▶${RESET} ${BOLD}$*${RESET}"; log "STEP: $*"; }
run_logged() {
local desc="$1"
shift
local start_ts pid code spinner elapsed symbol i
log "RUN: $*"
start_ts="$(date +%s)"
"$@" >> "$LOG_FILE" 2>&1 &
pid=$!
spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
i=0
while kill -0 "$pid" 2>/dev/null; do
elapsed=$(( $(date +%s) - start_ts ))
symbol="${spinner[$((i % ${#spinner[@]}))]}"
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$desc" "$elapsed" "$LOG_FILE"
sleep 1
i=$((i + 1))
done
wait "$pid"
code=$?
printf "\r%*s\r" 120 ""
if [[ "$code" -eq 0 ]]; then
ok "$desc"
else
fail "$desc"
fi
}
header() {
clear 2>/dev/null || true
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Bootstrap Installer${RESET} ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / Cockpit / Web Panel setup ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${GRAY}Repository:${RESET} ${REPO_URL}"
echo -e "${GRAY}Auth/install user:${RESET} ${INSTALL_USER}"
echo -e "${GRAY}Project dir:${RESET} ${PROJECT_DIR}"
echo -e "${GRAY}Log:${RESET} ${LOG_FILE}"
echo
}
require_root() {
[[ "$EUID" -eq 0 ]] || fail "Запусти от root: curl ... | sudo bash"
}
free_mb_for_path() {
local path="$1"
mkdir -p "$path" 2>/dev/null || true
df -Pm "$path" | awk 'NR==2 {print $4}'
}
check_password_state() {
local user="$1"
local hash
hash="$(getent shadow "$user" | cut -d: -f2 || true)"
if [[ -z "$hash" || "$hash" == "!" || "$hash" == "*" || "$hash" == "!!" ]]; then
warn "У пользователя ${user} пароль не задан или заблокирован. Для входа в панель выполни: passwd ${user}"
else
ok "У пользователя ${user} есть пароль для входа в панель"
fi
}
check_requirements() {
step "Проверяем минимальные системные требования"
if [[ "$SKIP_REQUIREMENTS" == "1" ]]; then
warn "Проверка требований отключена через VIRTUALITY_SKIP_REQUIREMENTS=1"
return 0
fi
local root_free var_free ram_mb cpu_cores
root_free="$(free_mb_for_path /)"
var_free="$(free_mb_for_path /var/lib 2>/dev/null || free_mb_for_path /)"
ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)"
cpu_cores="$(nproc 2>/dev/null || echo 1)"
if (( root_free >= MIN_ROOT_FREE_MB )); then
ok "Свободно на /: ${root_free} MB минимум ${MIN_ROOT_FREE_MB} MB"
else
fail "Недостаточно места на /: ${root_free} MB. Нужно минимум ${MIN_ROOT_FREE_MB} MB. Очисти диск: apt clean; journalctl --vacuum-time=3d"
fi
if (( var_free >= MIN_VAR_FREE_MB )); then
ok "Свободно для /var/lib: ${var_free} MB минимум ${MIN_VAR_FREE_MB} MB"
else
fail "Недостаточно места для VM-хранилища /var/lib: ${var_free} MB. Нужно минимум ${MIN_VAR_FREE_MB} MB"
fi
if (( ram_mb >= MIN_RAM_MB )); then
ok "RAM: ${ram_mb} MB минимум ${MIN_RAM_MB} MB"
else
warn "RAM: ${ram_mb} MB меньше рекомендуемых ${MIN_RAM_MB} MB. Установка возможна, но VM будут ограничены"
fi
if (( cpu_cores >= MIN_CPU_CORES )); then
ok "CPU cores: ${cpu_cores} минимум ${MIN_CPU_CORES}"
else
warn "CPU cores: ${cpu_cores} меньше рекомендуемых ${MIN_CPU_CORES}"
fi
if grep -E -q '(vmx|svm)' /proc/cpuinfo; then
ok "CPU virtualization vmx/svm найдена"
else
warn "CPU virtualization vmx/svm не найдена. Проверь BIOS/UEFI или настройки VPS"
fi
}
ensure_user() {
if [[ "$INSTALL_USER" == "root" ]]; then
ok "Пользователь авторизации: root"
check_password_state root
return 0
fi
if id "$INSTALL_USER" >/dev/null 2>&1; then
ok "Пользователь уже существует: ${INSTALL_USER}"
else
run_logged "Создан пользователь ${INSTALL_USER}" adduser --disabled-password --gecos "" "$INSTALL_USER"
warn "Пароль для ${INSTALL_USER} не задан. После установки выполни: sudo passwd ${INSTALL_USER}"
fi
usermod -aG sudo "$INSTALL_USER" >> "$LOG_FILE" 2>&1 || true
ok "Пользователь ${INSTALL_USER} добавлен в sudo"
check_password_state "$INSTALL_USER"
}
run_as_user() {
if [[ "$INSTALL_USER" == "root" ]]; then
bash -lc "$*"
else
sudo -H -u "$INSTALL_USER" bash -lc "$*"
fi
}
prepare_project_dir() {
mkdir -p "$PROJECT_BASE_DIR"
if [[ "$INSTALL_USER" == "root" ]]; then
chown root:root "$PROJECT_BASE_DIR"
else
chown "$INSTALL_USER:$INSTALL_USER" "$PROJECT_BASE_DIR"
fi
chmod 755 "$PROJECT_BASE_DIR"
ok "Рабочая директория подготовлена: ${PROJECT_BASE_DIR}"
}
header
step "Проверяем root и apt"
require_root
command -v apt >/dev/null 2>&1 || fail "apt не найден. Нужен Debian/Ubuntu"
ok "root подтверждён"
ok "apt найден"
check_requirements
step "Устанавливаем базовые пакеты"
run_logged "apt update выполнен" apt update
run_logged "Установлены sudo/git/curl/ca-certificates" apt install -y sudo git curl ca-certificates openssh-client nano htop
step "Создаём или проверяем пользователя"
ensure_user
step "Готовим рабочую директорию в /opt"
prepare_project_dir
step "Проверяем место после подготовки пользователя"
root_free_after="$(free_mb_for_path /)"
if (( root_free_after < MIN_ROOT_FREE_MB )); then
fail "После подготовки пользователя свободного места стало мало: ${root_free_after} MB на /. Очисти диск и повтори установку"
fi
ok "Место после подготовки пользователя: ${root_free_after} MB на /"
step "Клонируем или обновляем репозиторий"
if [[ -d "$PROJECT_DIR/.git" ]]; then
run_logged "Репозиторий обновлён" run_as_user "cd '$PROJECT_DIR' && git reset --hard origin/main && git pull"
else
run_logged "Репозиторий склонирован" run_as_user "cd '$PROJECT_BASE_DIR' && git clone '$REPO_URL' source"
if [[ -n "$SCRIPT_DIR" && -f "${SCRIPT_DIR}/install.sh" ]]; then
exec bash "${SCRIPT_DIR}/install.sh" "$@"
fi
step "Запускаем основной установщик ноды"
run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh"
step "Устанавливаем healthcheck-команду"
run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh"
step "Устанавливаем консольный dashboard"
run_logged "Console dashboard установлен" bash "$PROJECT_DIR/scripts/install_console_dashboard.sh"
step "Устанавливаем web-панель"
VIRTUALITY_AUTH_USER="$INSTALL_USER" VIRTUALITY_WEB_PORT="$WEB_PORT" bash "$PROJECT_DIR/scripts/install_web_panel.sh" >> "$LOG_FILE" 2>&1 &
web_pid=$!
web_start="$(date +%s)"
web_i=0
web_spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
while kill -0 "$web_pid" 2>/dev/null; do
web_elapsed=$(( $(date +%s) - web_start ))
printf "\r ${CYAN}%s${RESET} Web-панель устанавливается... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "${web_spinner[$((web_i % ${#web_spinner[@]}))]}" "$web_elapsed" "$LOG_FILE"
sleep 1
web_i=$((web_i + 1))
done
wait "$web_pid"
web_code=$?
printf "\r%*s\r" 120 ""
[[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена"
if [[ "$RUN_BRIDGE" == "1" ]]; then
step "Настраиваем bridge br0"
if [[ -z "$BRIDGE_IFACE" ]]; then
BRIDGE_IFACE="$(ip route | awk '/default/ {print $5; exit}')"
fi
if [[ -z "$BRIDGE_IFACE" ]]; then
warn "Не удалось определить интерфейс для br0. Пропускаю bridge setup"
else
warn "Bridge меняет сеть. Если SSH зависнет, нужен физический доступ или консоль провайдера."
bash "$PROJECT_DIR/scripts/setup_bridge_br0.sh" "$BRIDGE_IFACE" static >> "$LOG_FILE" 2>&1 && ok "Bridge br0 настроен на ${BRIDGE_IFACE}" || warn "Bridge setup завершился с предупреждением. Проверь лог"
fi
else
warn "Bridge br0 не настраивался автоматически. Запуск вручную: sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
fi
if [[ "$RUN_TEST_VM" == "1" ]]; then
step "Создаём тестовую VM"
run_logged "Тестовая VM создана" bash "$PROJECT_DIR/scripts/create_test_vm.sh" || warn "Тестовая VM не создана. Проверь ISO/bridge/log"
fi
step "Финальная диагностика"
if command -v vhealth >/dev/null 2>&1; then
vhealth >> "$LOG_FILE" 2>&1 && ok "vhealth: OK" || warn "vhealth вернул warnings/errors. Проверь: sudo vhealth"
else
warn "Команда vhealth не найдена"
fi
SERVER_IP="$(hostname -I | awk '{print $1}')"
echo
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality Bootstrap завершён${RESET} ${GREEN}${BOLD}│${RESET}"
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:9090"
echo -e "${BOLD}Web Panel:${RESET} http://${SERVER_IP}:${WEB_PORT}"
echo -e "${BOLD}Login:${RESET} ${INSTALL_USER} / пароль Linux-пользователя"
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
echo -e "${BOLD}Log:${RESET} ${LOG_FILE}"
echo
echo -e "${BOLD}После установки:${RESET}"
echo " passwd ${INSTALL_USER} # если пароль ещё не задан или заблокирован"
echo " sudo vhealth"
echo " cd ${PROJECT_DIR}"
echo " ip -br a"
echo
echo -e "${YELLOW}Важно:${RESET} bridge br0 по умолчанию не включается в one-command режиме, чтобы не уронить SSH."
echo "Для bridge: cd ${PROJECT_DIR} && sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
echo
tmp_installer="$(mktemp /tmp/virtuality-install.XXXXXX.sh)"
curl --connect-timeout 20 --max-time 180 -fsSL "$INSTALL_URL" -o "$tmp_installer"
exec bash "$tmp_installer" "$@"
+143
Просмотреть файл
@@ -0,0 +1,143 @@
# Установочный образ Virtuality
Virtuality можно поставить двумя способами:
1. **Скрипт на готовый сервер** — `curl ... install.sh | bash` (см. README).
2. **Установочный ISO** — загрузочный образ «с нуля», как у Proxmox: записываете на флешку, загружаетесь, отвечаете на 3 экрана, после перезагрузки получаете готовую ноду.
Этот документ про второй способ.
---
## Что внутри образа
Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64; поддержка до 2031 года) и дополняется:
```text
/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall)
/virtuality/source # исходники Virtuality (git-репозиторий нужной версии)
/virtuality/source/wheels # Python-колёса web-панели для офлайн-установки
/virtuality/image.env # параметры сборки: порт панели, автообновление, пользователь
/virtuality/virtuality-firstboot.service
/boot/grub/grub.cfg # меню «Install Virtuality X.Y.Z»
```
Загрузочные записи BIOS и UEFI берутся из оригинального ISO без изменений.
## Как проходит установка
1. Загрузка с ISO → пункт **Install Virtuality**.
2. Установщик Ubuntu спрашивает только **сеть**, **диск** и **пользователя** (имя, пароль, hostname). Язык, раскладка, SSH-сервер и остальное заданы автоматически.
3. Установщик копирует Virtuality в `/opt/virtuality/source` и включает `virtuality-firstboot.service`.
4. После перезагрузки `virtuality-firstboot` (нужен интернет) ставит KVM/QEMU/libvirt, Cockpit, web-панель, `vhealth` и консольный dashboard. Обычно это 5–15 минут. Если сети нет или apt упал, попытка повторяется каждую минуту до успеха.
5. Готово:
```text
Web-панель: http://SERVER_IP:8088 (логин — пользователь, созданный в установщике)
Cockpit: https://SERVER_IP:9090
```
Адрес панели также показывается на экране входа консоли. Ход первой настройки:
```bash
sudo journalctl -fu virtuality-firstboot
cat /var/log/virtuality/firstboot.log
```
---
## Сборка ISO
Нужна Linux-машина (root не нужен), ~10 GB свободного места и пакеты:
```bash
sudo apt install -y xorriso git curl python3-pip
```
Сборка:
```bash
git clone https://github.com/viktor138irk/virtuality.git
cd virtuality
make iso # amd64
make iso ARCH=arm64 # ARM64-серверы с UEFI
```
Скрипт сам скачает последний Ubuntu 26.04.x live-server ISO, проверит SHA256 и положит результат в `dist/`:
```text
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-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
--locale / --keyboard / --timezone
--no-wheels не класть Python-колёса (тогда первая загрузка скачает их из PyPI)
--repo-url URL откуда нода будет получать обновления
```
Через make параметры передаются так:
```bash
make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow"
```
### Полностью автоматическая установка
Для массовой раскатки можно собрать образ, который не задаёт вопросов:
```bash
make iso ISO_ARGS="--unattended --username admin --password-hash '$(openssl passwd -6)' --hostname node01"
```
> ⚠️ Такой образ загружается в установку сам (через 10 секунд в меню) и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`. Не оставляйте такую флешку в серверах.
---
## Запись на флешку
Linux / macOS:
```bash
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.
Проверка целостности:
```bash
cd dist && sha256sum -c virtuality-0.10.0-ubuntu-26.04-amd64.iso.sha256
```
---
## Сборка в GitHub Actions
Workflow `.github/workflows/image.yml` собирает ISO:
- автоматически при пуше тега `v*` (amd64 и arm64);
- вручную: Actions → **Build ISO image** → Run workflow.
Образы Ubuntu Server больше лимита GitHub Releases (2 GB на файл), поэтому ISO публикуются как **artifacts** workflow и хранятся 30 дней.
---
## Ограничения
- База образа — 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`.
Двоичные данные
Двоичный файл не отображается.

После

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

Двоичный файл не отображается.

После

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

Двоичный файл не отображается.

После

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

Двоичные данные
Двоичный файл не отображается.

После

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

Двоичные данные
Двоичный файл не отображается.

После

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

Двоичные данные
Двоичный файл не отображается.

После

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

Двоичные данные
Двоичный файл не отображается.

После

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

+31
Просмотреть файл
@@ -0,0 +1,31 @@
#cloud-config
# Virtuality installer configuration for Ubuntu Server 24.04 (subiquity autoinstall).
# build-iso.sh puts this file at /autoinstall.yaml on the ISO.
# @@VARIABLES@@ are substituted at build time.
autoinstall:
version: 1
# The installer asks only for these screens; everything else is automated.
interactive-sections:
@@INTERACTIVE_SECTIONS@@
locale: @@LOCALE@@
keyboard:
layout: @@KEYBOARD@@
timezone: @@TIMEZONE@@
source:
id: ubuntu-server
ssh:
install-server: true
allow-pw: true
@@IDENTITY@@
storage:
layout:
name: lvm
sizing-policy: all
late-commands:
- mkdir -p /target/opt/virtuality /target/etc/virtuality
- cp -a /cdrom/virtuality/source /target/opt/virtuality/source
- chown -R root:root /target/opt/virtuality
- chmod -R u+w /target/opt/virtuality/source
- cp /cdrom/virtuality/image.env /target/etc/virtuality/image.env
- cp /cdrom/virtuality/virtuality-firstboot.service /target/etc/systemd/system/virtuality-firstboot.service
- curtin in-target --target=/target -- systemctl enable virtuality-firstboot.service
+250
Просмотреть файл
@@ -0,0 +1,250 @@
#!/usr/bin/env bash
set -euo pipefail
# ==========================================================
# Virtuality ISO builder
# Remasters the official Ubuntu Server LTS live ISO (26.04 by default) into a
# Virtuality installer: Ubuntu autoinstall + bundled Virtuality
# source and Python wheels + first boot setup service.
#
# Usage: image/build-iso.sh [options] (see --help)
# Needs: xorriso, git, curl, python3 with pip, sha256sum.
# Does not need root.
# ==========================================================
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE_DIR="${REPO_ROOT}/image"
ARCH="amd64"
UBUNTU_SERIES="26.04"
UBUNTU_ISO=""
CACHE_DIR="${VIRTUALITY_ISO_CACHE:-${REPO_ROOT}/.cache/iso}"
OUTPUT_DIR="${REPO_ROOT}/dist"
GIT_REF="HEAD"
REPO_URL="https://github.com/viktor138irk/virtuality.git"
WEB_PORT="8088"
AUTO_UPDATE="1"
UNATTENDED="0"
USERNAME=""
PASSWORD_HASH=""
HOSTNAME_VALUE="virtuality"
LOCALE="en_US.UTF-8"
KEYBOARD="us"
TIMEZONE="Etc/UTC"
WITH_WHEELS="1"
TARGET_PYTHON=""
usage() {
cat <<EOF
Usage: $(basename "$0") [options]
Options:
--arch amd64|arm64 Target architecture (default: ${ARCH})
--ubuntu 26.04|24.04 Ubuntu Server LTS release to base on (default: ${UBUNTU_SERIES})
--ubuntu-iso PATH Use a local Ubuntu live-server ISO of that release instead of downloading
--cache-dir DIR Where downloaded Ubuntu ISOs are kept (default: ${CACHE_DIR})
--output DIR Output directory (default: ${OUTPUT_DIR})
--ref GIT_REF Git ref of Virtuality to bundle (default: ${GIT_REF})
--repo-url URL Update remote configured on installed nodes (default: ${REPO_URL})
--web-port PORT Web panel port on installed nodes (default: ${WEB_PORT})
--no-auto-update Disable the nightly GitHub auto-update on installed nodes
--locale LOCALE Installer/system locale (default: ${LOCALE})
--keyboard LAYOUT Keyboard layout (default: ${KEYBOARD})
--timezone TZ System timezone (default: ${TIMEZONE})
--no-wheels Do not bundle Python wheels (first boot downloads them from PyPI)
Unattended mode (no questions at all, ERASES THE LARGEST DISK):
--unattended Fully automatic install
--username NAME Admin user (panel login), required with --unattended
--password-hash HASH crypt(3) hash, e.g. from: openssl passwd -6
--hostname NAME Hostname (default: ${HOSTNAME_VALUE})
Without --unattended the installer asks for network, disk and user
like a regular Ubuntu Server install; everything else is automated.
EOF
}
die() { echo "ERROR: $*" >&2; exit 1; }
say() { echo "==> $*"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--ubuntu) UBUNTU_SERIES="$2"; shift 2 ;;
--ubuntu-iso) UBUNTU_ISO="$2"; shift 2 ;;
--cache-dir) CACHE_DIR="$2"; shift 2 ;;
--output) OUTPUT_DIR="$2"; shift 2 ;;
--ref) GIT_REF="$2"; shift 2 ;;
--repo-url) REPO_URL="$2"; shift 2 ;;
--web-port) WEB_PORT="$2"; shift 2 ;;
--no-auto-update) AUTO_UPDATE="0"; shift ;;
--locale) LOCALE="$2"; shift 2 ;;
--keyboard) KEYBOARD="$2"; shift 2 ;;
--timezone) TIMEZONE="$2"; shift 2 ;;
--no-wheels) WITH_WHEELS="0"; shift ;;
--unattended) UNATTENDED="1"; shift ;;
--username) USERNAME="$2"; shift 2 ;;
--password-hash) PASSWORD_HASH="$2"; shift 2 ;;
--hostname) HOSTNAME_VALUE="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die "unknown option: $1" ;;
esac
done
# Python of the target release decides which wheels are bundled.
case "$UBUNTU_SERIES" in
26.04) TARGET_PYTHON="3.14" ;;
24.04) TARGET_PYTHON="3.12" ;;
*) die "unsupported --ubuntu ${UBUNTU_SERIES} (26.04 or 24.04)" ;;
esac
case "$ARCH" in
amd64) PY_PLATFORM_ARCH="x86_64"; MIRROR="https://releases.ubuntu.com/${UBUNTU_SERIES}" ;;
arm64) PY_PLATFORM_ARCH="aarch64"; MIRROR="https://cdimage.ubuntu.com/releases/${UBUNTU_SERIES}/release" ;;
*) die "unsupported arch: $ARCH (amd64 or arm64)" ;;
esac
[[ "$WEB_PORT" =~ ^[0-9]+$ ]] && (( WEB_PORT >= 1 && WEB_PORT <= 65535 )) || die "invalid --web-port: $WEB_PORT"
if [[ "$UNATTENDED" == "1" ]]; then
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--unattended needs a valid --username"
[[ "$PASSWORD_HASH" == \$* ]] || die "--unattended needs --password-hash (generate: openssl passwd -6)"
[[ "$HOSTNAME_VALUE" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$ ]] || die "invalid --hostname"
fi
for cmd in xorriso git curl python3 sha256sum md5sum; do
command -v "$cmd" >/dev/null 2>&1 || die "$cmd not found (Ubuntu: sudo apt install xorriso git curl python3-pip)"
done
VERSION="$(git -C "$REPO_ROOT" show "${GIT_REF}:VERSION" | tr -d '[:space:]')"
[[ -n "$VERSION" ]] || die "cannot read VERSION at ${GIT_REF}"
SUFFIX=""
[[ "$UNATTENDED" == "1" ]] && SUFFIX="-unattended"
OUTPUT_ISO="${OUTPUT_DIR}/virtuality-${VERSION}-ubuntu-${UBUNTU_SERIES}-${ARCH}${SUFFIX}.iso"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/virtuality-iso.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT
mkdir -p "$OUTPUT_DIR" "$CACHE_DIR"
# ---------------------------------------------------------- Ubuntu ISO
if [[ -z "$UBUNTU_ISO" ]]; then
say "Resolving the latest Ubuntu ${UBUNTU_SERIES} live-server ISO for ${ARCH}"
curl -fsSL --retry 3 "${MIRROR}/SHA256SUMS" -o "${WORK_DIR}/SHA256SUMS"
iso_name="$(grep -oE "ubuntu-${UBUNTU_SERIES}(\.[0-9]+)*-live-server-${ARCH}\.iso" "${WORK_DIR}/SHA256SUMS" | sort -uV | tail -n1)"
[[ -n "$iso_name" ]] || die "live-server ISO for ${ARCH} not found in ${MIRROR}/SHA256SUMS"
expected_sha="$(awk -v n="*${iso_name}" '$2 == n || $2 == substr(n, 2) {print $1}' "${WORK_DIR}/SHA256SUMS")"
UBUNTU_ISO="${CACHE_DIR}/${iso_name}"
if [[ -f "$UBUNTU_ISO" ]] && echo "${expected_sha} ${UBUNTU_ISO}" | sha256sum -c --status; then
say "Using cached ${iso_name}"
else
say "Downloading ${iso_name}"
curl -fL --retry 5 -C - -o "${UBUNTU_ISO}.part" "${MIRROR}/${iso_name}"
mv "${UBUNTU_ISO}.part" "$UBUNTU_ISO"
echo "${expected_sha} ${UBUNTU_ISO}" | sha256sum -c --status || die "checksum mismatch for ${iso_name}"
fi
say "Checksum OK: ${expected_sha}"
fi
[[ -f "$UBUNTU_ISO" ]] || die "Ubuntu ISO not found: $UBUNTU_ISO"
# ---------------------------------------------------------- payload
PAYLOAD="${WORK_DIR}/virtuality"
mkdir -p "$PAYLOAD"
say "Bundling Virtuality ${VERSION} (${GIT_REF})"
git clone --quiet --no-hardlinks "$REPO_ROOT" "${PAYLOAD}/source"
git -C "${PAYLOAD}/source" checkout --quiet --detach "$(git -C "$REPO_ROOT" rev-parse "${GIT_REF}^{commit}")"
git -C "${PAYLOAD}/source" remote set-url origin "$REPO_URL"
if [[ "$WITH_WHEELS" == "1" ]]; then
say "Downloading Python ${TARGET_PYTHON} wheels for ${PY_PLATFORM_ARCH}"
platform_args=(--platform "manylinux2014_${PY_PLATFORM_ARCH}")
for glibc_minor in 17 28 31 34 35 39; do
platform_args+=(--platform "manylinux_2_${glibc_minor}_${PY_PLATFORM_ARCH}")
done
python3 -m pip download --quiet --disable-pip-version-check \
-r "${PAYLOAD}/source/web/requirements.txt" -d "${PAYLOAD}/source/wheels" \
--only-binary=:all: --implementation cp --python-version "$TARGET_PYTHON" \
--abi "cp${TARGET_PYTHON/./}" --abi abi3 --abi none "${platform_args[@]}"
fi
cat > "${PAYLOAD}/image.env" <<EOF
# Written by image/build-iso.sh, read by scripts/virtuality_firstboot.sh
VIRTUALITY_IMAGE_VERSION=${VERSION}
VIRTUALITY_IMAGE_BUILT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
VIRTUALITY_WEB_PORT=${WEB_PORT}
VIRTUALITY_AUTO_UPDATE=${AUTO_UPDATE}
EOF
[[ "$UNATTENDED" == "1" ]] && echo "VIRTUALITY_USER=${USERNAME}" >> "${PAYLOAD}/image.env"
cp "${IMAGE_DIR}/files/virtuality-firstboot.service" "${PAYLOAD}/virtuality-firstboot.service"
# ---------------------------------------------------------- autoinstall
if [[ "$UNATTENDED" == "1" ]]; then
interactive_sections=" []"
identity=" identity:
hostname: ${HOSTNAME_VALUE}
username: ${USERNAME}
realname: Virtuality Admin
password: '${PASSWORD_HASH}'"
else
interactive_sections=" - network
- storage
- identity"
identity=""
fi
python3 - "${IMAGE_DIR}/autoinstall.yaml" "${WORK_DIR}/autoinstall.yaml" <<PYEOF
import sys
text = open(sys.argv[1]).read()
values = {
"@@INTERACTIVE_SECTIONS@@": """${interactive_sections}""",
"@@IDENTITY@@": """${identity}""",
"@@LOCALE@@": "${LOCALE}",
"@@KEYBOARD@@": "${KEYBOARD}",
"@@TIMEZONE@@": "${TIMEZONE}",
}
for key, value in values.items():
text = text.replace(key, value)
open(sys.argv[2], "w").write(text)
PYEOF
python3 -c 'import sys, yaml; data = yaml.safe_load(open(sys.argv[1])); assert data["autoinstall"]["version"] == 1' "${WORK_DIR}/autoinstall.yaml" 2>/dev/null \
|| python3 -c 'import sys, json; print("PyYAML not available, skipping YAML validation")'
# ---------------------------------------------------------- boot menu
xorriso -osirrox on -indev "$UBUNTU_ISO" \
-extract /boot/grub/grub.cfg "${WORK_DIR}/grub.cfg" \
-extract /md5sum.txt "${WORK_DIR}/md5sum.txt" >/dev/null 2>&1 || die "cannot read grub.cfg from $UBUNTU_ISO"
chmod u+w "${WORK_DIR}/grub.cfg" "${WORK_DIR}/md5sum.txt"
python3 - "${WORK_DIR}/grub.cfg" "$UNATTENDED" "$VERSION" <<'PYEOF'
import re
import sys
path, unattended, version = sys.argv[1], sys.argv[2] == "1", sys.argv[3]
text = open(path).read()
# /autoinstall.yaml applies to every entry, so all Ubuntu entries become Virtuality entries.
suffix = " (UNATTENDED: erases disk)" if unattended else ""
text = text.replace('menuentry "Try or Install Ubuntu Server"', 'menuentry "Install Virtuality %s%s"' % (version, suffix), 1)
text = text.replace('menuentry "Ubuntu Server with the HWE kernel"', 'menuentry "Install Virtuality %s with the HWE kernel%s"' % (version, suffix), 1)
if "Install Virtuality" not in text:
raise SystemExit("unexpected grub.cfg menu titles")
if unattended:
text = re.sub(r"^(\s*linux\s+\S+\s+)---", r"\1autoinstall ---", text, flags=re.M)
text = re.sub(r"^set timeout=\d+", "set default=0\nset timeout=10", text, count=1, flags=re.M)
open(path, "w").write(text)
PYEOF
new_md5="$(md5sum "${WORK_DIR}/grub.cfg" | cut -d' ' -f1)"
sed -i "s|^[0-9a-f]\{32\} \./boot/grub/grub.cfg$|${new_md5} ./boot/grub/grub.cfg|" "${WORK_DIR}/md5sum.txt"
# ---------------------------------------------------------- repack
say "Writing ${OUTPUT_ISO}"
rm -f "$OUTPUT_ISO"
chmod -R a+rX,go-w "$PAYLOAD"
if ! xorriso -indev "$UBUNTU_ISO" -outdev "$OUTPUT_ISO" \
-uid 0 -gid 0 \
-map "$PAYLOAD" /virtuality \
-map "${WORK_DIR}/autoinstall.yaml" /autoinstall.yaml \
-map "${WORK_DIR}/grub.cfg" /boot/grub/grub.cfg \
-map "${WORK_DIR}/md5sum.txt" /md5sum.txt \
-boot_image any replay > "${WORK_DIR}/xorriso.log" 2>&1; then
tail -n 30 "${WORK_DIR}/xorriso.log" >&2
die "xorriso failed"
fi
[[ -s "$OUTPUT_ISO" ]] || die "xorriso did not produce $OUTPUT_ISO"
(cd "$OUTPUT_DIR" && sha256sum "$(basename "$OUTPUT_ISO")" > "$(basename "$OUTPUT_ISO").sha256")
say "Done: ${OUTPUT_ISO} ($(du -h "$OUTPUT_ISO" | cut -f1))"
say "SHA256: $(cut -d' ' -f1 "${OUTPUT_ISO}.sha256")"
+19
Просмотреть файл
@@ -0,0 +1,19 @@
[Unit]
Description=Virtuality first boot setup
Documentation=https://github.com/viktor138irk/virtuality/blob/main/docs/IMAGE.md
Wants=network-online.target
After=network-online.target
ConditionPathExists=!/var/lib/virtuality/.firstboot-done
StartLimitIntervalSec=0
[Service]
# simple, not oneshot: boot must not wait for a multi-minute package install.
Type=simple
ExecStart=/bin/bash /opt/virtuality/source/scripts/virtuality_firstboot.sh
Restart=on-failure
RestartSec=60
StandardOutput=journal+console
StandardError=journal+console
[Install]
WantedBy=multi-user.target
+3 -2
Просмотреть файл
@@ -21,7 +21,7 @@ if [[ "${EUID}" -ne 0 ]]; then
exit 1
fi
chmod +x "$tmp_installer"
exec sudo --preserve-env=VIRTUALITY_INSTALL_URL,VIRTUALITY_USER,VIRTUALITY_WEB_PORT,VIRTUALITY_MIN_ROOT_FREE_MB,VIRTUALITY_MIN_VAR_FREE_MB,VIRTUALITY_MIN_RAM_MB,VIRTUALITY_MIN_CPU_CORES,VIRTUALITY_SKIP_REQUIREMENTS,VIRTUALITY_SETUP_BRIDGE,VIRTUALITY_BRIDGE_IFACE,VIRTUALITY_CREATE_TEST_VM,VIRTUALITY_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_CLEAN_BEFORE_INSTALL bash "$tmp_installer" "$@"
exec sudo --preserve-env=VIRTUALITY_INSTALL_URL,VIRTUALITY_USER,VIRTUALITY_WEB_PORT,VIRTUALITY_MIN_ROOT_FREE_MB,VIRTUALITY_MIN_VAR_FREE_MB,VIRTUALITY_MIN_RAM_MB,VIRTUALITY_MIN_CPU_CORES,VIRTUALITY_SKIP_REQUIREMENTS,VIRTUALITY_SETUP_BRIDGE,VIRTUALITY_BRIDGE_IFACE,VIRTUALITY_CREATE_TEST_VM,VIRTUALITY_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_CLEAN_BEFORE_INSTALL,VIRTUALITY_AUTO_UPDATE bash "$tmp_installer" "$@"
fi
echo "Root or sudo is required. Run: curl -fsSL $SELF_INSTALL_URL | sudo bash" >&2
exit 1
@@ -45,6 +45,7 @@ MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
INSTALL_VERBOSE="${VIRTUALITY_INSTALL_VERBOSE:-0}"
CLEAN_BEFORE_INSTALL="${VIRTUALITY_CLEAN_BEFORE_INSTALL:-0}"
export DEBIAN_FRONTEND=noninteractive
ESC="\033"
RESET="${ESC}[0m"
@@ -277,7 +278,7 @@ if [[ "$CLEAN_BEFORE_INSTALL" == "1" && -x "$PROJECT_DIR/scripts/clean_install.s
fi
step "Запускаем основной установщик ноды"
run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh"
run_logged "Virtuality Node установлен" env VIRTUALITY_USER="$INSTALL_USER" bash "$PROJECT_DIR/install_virtuality_node.sh"
step "Устанавливаем healthcheck-команду"
run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh"
+29 -8
Просмотреть файл
@@ -22,6 +22,7 @@ MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}"
MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
export DEBIAN_FRONTEND=noninteractive
ESC="\033"
RESET="${ESC}[0m"
@@ -91,6 +92,22 @@ run_logged() {
fi
}
# Installs the packages this distribution has; package names differ between
# Ubuntu 24.04/26.04 and Debian 13 (e.g. qemu-kvm became a virtual package).
install_available() {
local description="$1"
shift
local pkg available=()
for pkg in "$@"; do
if apt-cache show "$pkg" >/dev/null 2>&1; then
available+=("$pkg")
else
warn "Пакет ${pkg} недоступен в этом дистрибутиве, пропускаю"
fi
done
run_logged "$description" apt install -y "${available[@]}"
}
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
service_enabled() { systemctl is-enabled "$1" 2>/dev/null || echo "disabled"; }
free_mb_for_path() { local path="$1"; mkdir -p "$path" 2>/dev/null || true; df -Pm "$path" | awk 'NR==2 {print $4}'; }
@@ -152,17 +169,21 @@ step "Обновляем apt cache"
run_logged "apt update выполнен" apt update
step "Устанавливаем базовые утилиты и зависимости диагностики"
run_logged "Базовые пакеты и диагностические утилиты установлены" apt install -y \
install_available "Базовые пакеты и диагностические утилиты установлены" \
curl wget git nano htop btop tree ncdu jq unzip xz-utils tar gzip ca-certificates gnupg lsb-release \
software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables dnsutils net-tools openssh-client
software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables bind9-dnsutils net-tools openssh-client
step "Устанавливаем KVM/QEMU/libvirt"
run_logged "Пакеты виртуализации установлены" apt install -y \
qemu-kvm qemu-utils libvirt-daemon-system libvirt-clients virtinst \
bridge-utils dnsmasq-base ovmf swtpm cloud-image-utils
case "$(uname -m)" in
aarch64|arm64) QEMU_SYSTEM_PACKAGES=(qemu-system-arm qemu-efi-aarch64) ;;
*) QEMU_SYSTEM_PACKAGES=(qemu-system-x86 ovmf) ;;
esac
install_available "Пакеты виртуализации установлены" \
"${QEMU_SYSTEM_PACKAGES[@]}" qemu-utils libvirt-daemon-system libvirt-clients virtinst \
bridge-utils dnsmasq-base swtpm cloud-image-utils
step "Устанавливаем Cockpit и модули"
run_logged "Cockpit установлен" apt install -y \
install_available "Cockpit установлен" \
cockpit cockpit-machines cockpit-networkmanager cockpit-storaged cockpit-packagekit
step "Создаём структуру Virtuality"
@@ -174,14 +195,14 @@ ok "Images: ${IMAGES_DIR}"
ok "Backups: ${BACKUP_DIR}"
step "Добавляем пользователя в группы libvirt/kvm"
REAL_USER="${SUDO_USER:-root}"
REAL_USER="${VIRTUALITY_USER:-${SUDO_USER:-root}}"
if [[ "$REAL_USER" != "root" ]]; then
usermod -aG libvirt "$REAL_USER" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить ${REAL_USER} в libvirt"
usermod -aG kvm "$REAL_USER" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить ${REAL_USER} в kvm"
ok "Пользователь ${REAL_USER} добавлен в группы libvirt/kvm"
warn "Чтобы группы применились, нужно выйти из SSH и зайти снова"
else
warn "Запуск под root без SUDO_USER; пользователь не добавлен в группы"
warn "Запуск под root без SUDO_USER/VIRTUALITY_USER; пользователь не добавлен в группы"
fi
step "Включаем systemd-сервисы"
+3
Просмотреть файл
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -p no:cacheprovider
+4
Просмотреть файл
@@ -0,0 +1,4 @@
# Development and CI tools
pytest==8.3.4
httpx==0.28.1
pyflakes==3.2.0
+5
Просмотреть файл
@@ -96,6 +96,11 @@ diag_network() {
write_state "idle" "Автообновление: новых обновлений нет"
exit 0
fi
if [ -n "$latest" ] && [ "$current" != "unknown" ] && ! git merge-base --is-ancestor "$current" "$latest" 2>/dev/null; then
log "local build ${current:0:12} is not behind ${REMOTE}/${BRANCH} (${latest:0:12}); skip to avoid a downgrade"
write_state "idle" "Автообновление: установленная сборка новее или отличается от ${BRANCH}, обновление пропущено"
exit 0
fi
log "update may be available or current commit unknown: ${current:0:12} -> ${latest:0:12}"
else
log "git fetch failed; diagnostics and apply ZIP fallback will be used"
+5 -2
Просмотреть файл
@@ -5,7 +5,8 @@ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DASH_SRC="${REPO_DIR}/scripts/virtuality_console_dashboard.sh"
DASH_DST="/usr/local/bin/virtuality-console-dashboard"
SERVICE_FILE="/etc/systemd/system/virtuality-console-dashboard.service"
TARGET_USER="${SUDO_USER:-${USER:-root}}"
export DEBIAN_FRONTEND=noninteractive
TARGET_USER="${VIRTUALITY_USER:-${SUDO_USER:-${USER:-root}}}"
if [[ "$EUID" -ne 0 ]]; then
echo "Ошибка: запусти через sudo: sudo bash scripts/install_console_dashboard.sh"
@@ -49,7 +50,9 @@ EOF
systemctl daemon-reload
systemctl disable --now getty@tty1.service >/dev/null 2>&1 || true
systemctl enable --now virtuality-console-dashboard.service
systemctl enable virtuality-console-dashboard.service
# --no-block: the unit is ordered after multi-user.target, which may still be starting (first boot).
systemctl start --no-block virtuality-console-dashboard.service
USER_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6 || true)"
if [[ -n "$USER_HOME" && -d "$USER_HOME" ]]; then
-20
Просмотреть файл
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="${VIRTUALITY_APP_DIR:-/opt/virtuality/web}"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DISK_IMAGES_DIR="/var/lib/virtuality/disk-images"
mkdir -p "$DISK_IMAGES_DIR"
chmod 755 "$DISK_IMAGES_DIR"
if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then
python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py"
else
echo "patch_disk_images.py not found" >&2
exit 1
fi
systemctl restart virtuality-web.service
echo "Disk image support installed. Open: /disk-images"
+1 -1
Просмотреть файл
@@ -4,7 +4,7 @@ set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="${REPO_DIR}/scripts/virtuality_healthcheck.sh"
DST="/usr/local/bin/vhealth"
TARGET_USER="${SUDO_USER:-${USER:-root}}"
TARGET_USER="${VIRTUALITY_USER:-${SUDO_USER:-${USER:-root}}}"
if [[ "$EUID" -ne 0 ]]; then
echo "Ошибка: запусти через sudo: sudo bash scripts/install_healthcheck_command.sh"
+93 -127
Просмотреть файл
@@ -11,15 +11,52 @@ VENV_DIR="/opt/virtuality/venv"
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
PORT="${VIRTUALITY_WEB_PORT:-8088}"
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
NETWORK_SERVICE_FILE="/etc/systemd/system/virtuality-network.service"
LOG_DIR="/var/log/virtuality"
LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
PROFILE_DIR="/var/lib/virtuality/config"
PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
NODE_CONFIG_FILE="${PROFILE_DIR}/web.env"
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
WHEELS_DIR="${VIRTUALITY_WHEELS_DIR:-${REPO_DIR}/wheels}"
TOTAL_STEPS=13
export DEBIAN_FRONTEND=noninteractive
# Settings survive updates: explicit env > saved node config > previous install > defaults.
saved_setting() {
local key="$1"
[[ -f "$NODE_CONFIG_FILE" ]] || return 0
sed -n "s/^${key}=//p" "$NODE_CONFIG_FILE" | tail -n1 | tr -d "\"'"
}
legacy_auth_user() {
if [[ -f "${APP_DIR}/.env" ]]; then
sed -n 's/^VIRTUALITY_AUTH_USER=//p' "${APP_DIR}/.env" | tail -n1
fi
}
legacy_port() {
if [[ -f "$SERVICE_FILE" ]]; then
sed -n 's/.*--port \([0-9][0-9]*\).*/\1/p' "$SERVICE_FILE" | tail -n1
fi
}
first_human_user() {
getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {print $1; exit}'
}
PORT="${VIRTUALITY_WEB_PORT:-$(saved_setting VIRTUALITY_WEB_PORT)}"
PORT="${PORT:-$(legacy_port)}"
PORT="${PORT:-8088}"
AUTH_USER="${VIRTUALITY_AUTH_USER:-$(saved_setting VIRTUALITY_AUTH_USER)}"
AUTH_USER="${AUTH_USER:-$(legacy_auth_user)}"
AUTH_USER="${AUTH_USER:-${SUDO_USER:-}}"
AUTH_USER="${AUTH_USER:-$(first_human_user)}"
AUTH_USER="${AUTH_USER:-root}"
AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-$(saved_setting VIRTUALITY_AUTO_UPDATE)}"
AUTO_UPDATE="${AUTO_UPDATE:-1}"
WEB_HOST="${VIRTUALITY_WEB_HOST:-$(saved_setting VIRTUALITY_WEB_HOST)}"
WEB_HOST="${WEB_HOST:-0.0.0.0}"
COOKIE_SECURE="${VIRTUALITY_COOKIE_SECURE:-$(saved_setting VIRTUALITY_COOKIE_SECURE)}"
COOKIE_SECURE="${COOKIE_SECURE:-0}"
CURRENT_STEP=0
ESC="\033"
@@ -75,16 +112,6 @@ run_logged() {
fi
}
restore_canonical_templates() {
local templates=("_sidebar.html" "dashboard.html" "vm_create.html" "vm_detail.html" "iso.html" "disk_images.html" "operations.html" "operation_detail.html" "host.html" "network.html" "logs.html" "update.html")
local name=""
for name in "${templates[@]}"; do
if [[ -f "${WEB_DIR}/templates/${name}" ]]; then
cp "${WEB_DIR}/templates/${name}" "${APP_DIR}/templates/${name}"
fi
done
}
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
require_root() { [[ "$EUID" -eq 0 ]] || fail "Запусти через sudo: sudo bash scripts/install_web_panel.sh"; }
json_value() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$PROFILE_FILE" "$1" 2>/dev/null || true; }
@@ -166,114 +193,8 @@ fi
step "Копируем web-панель в /opt/virtuality"
run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality
run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/"
if [[ -f "${REPO_DIR}/scripts/patch_web_console.py" ]]; then
run_logged "noVNC web-console patch применён" python3 "${REPO_DIR}/scripts/patch_web_console.py" "${APP_DIR}/app.py"
else
warn "patch_web_console.py не найден, noVNC console patch пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_upload_compat.py" ]]; then
run_logged "upload compatibility patch применён" python3 "${REPO_DIR}/scripts/patch_upload_compat.py" "${APP_DIR}/app.py"
else
warn "patch_upload_compat.py не найден, совместимость загрузки файлов пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" ]]; then
run_logged "upload navigation guard patch применён" python3 "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" "${APP_DIR}/app.py"
else
warn "patch_upload_navigation_guard.py не найден, защита загрузок от переходов пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then
run_logged "disk images patch применён" python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py"
else
warn "patch_disk_images.py не найден, менеджер дисковых образов пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_boot_order.py" ]]; then
run_logged "VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_vm_boot_order.py" "${APP_DIR}/app.py"
else
warn "patch_vm_boot_order.py не найден, порядок загрузки VM пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" ]]; then
run_logged "existing VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" "${APP_DIR}/app.py"
else
warn "patch_existing_vm_boot_order.py не найден, порядок загрузки существующих VM пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_resources.py" ]]; then
run_logged "existing VM resources patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_resources.py" "${APP_DIR}/app.py"
else
warn "patch_existing_vm_resources.py не найден, ресурсы существующих VM пропущены"
fi
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" ]]; then
run_logged "existing VM ISO mount patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" "${APP_DIR}/app.py"
else
warn "patch_existing_vm_iso_mount.py не найден, монтирование ISO в VM пропущено"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" ]]; then
run_logged "VM detail resource layout patch применён" python3 "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" "${APP_DIR}/app.py"
else
warn "patch_vm_detail_resource_layout.py не найден, раскладка ресурсов VM пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" ]]; then
run_logged "legacy boot order cleanup patch применён" python3 "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" "${APP_DIR}/app.py"
else
warn "patch_remove_legacy_boot_order_card.py не найден, удаление старого блока порядка загрузки пропущено"
fi
run_logged "Канонические шаблоны панели восстановлены после VM-патчей" restore_canonical_templates
if [[ -f "${REPO_DIR}/scripts/patch_disk_archives.py" ]]; then
run_logged "disk archive import patch применён" python3 "${REPO_DIR}/scripts/patch_disk_archives.py" "${APP_DIR}/app.py"
else
warn "patch_disk_archives.py не найден, импорт архивов дисков пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" ]]; then
run_logged "DHCP leases empty-state patch применён" python3 "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" "${APP_DIR}/app.py"
else
warn "patch_dhcp_leases_empty.py не найден, диагностика DHCP leases пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_diagnostics.py" ]]; then
run_logged "network diagnostics patch применён" python3 "${REPO_DIR}/scripts/patch_network_diagnostics.py" "${APP_DIR}/app.py"
else
warn "patch_network_diagnostics.py не найден, диагностика сети пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_ranges.py" ]]; then
run_logged "network port ranges patch применён" python3 "${REPO_DIR}/scripts/patch_network_ranges.py" "${APP_DIR}/app.py"
else
warn "patch_network_ranges.py не найден, поддержка диапазонов портов пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" ]]; then
run_logged "network bridge forward patch применён" python3 "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" "${APP_DIR}/app.py"
else
warn "patch_network_bridge_forwards.py не найден, проброс bridge/static VM пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_nat_errors.py" ]]; then
run_logged "network NAT error patch применён" python3 "${REPO_DIR}/scripts/patch_network_nat_errors.py" "${APP_DIR}/app.py"
else
warn "patch_network_nat_errors.py не найден, безопасные ошибки NAT пропущены"
fi
if [[ -f "${REPO_DIR}/scripts/patch_logs_center.py" ]]; then
run_logged "logs center patch применён" python3 "${REPO_DIR}/scripts/patch_logs_center.py" "${APP_DIR}/app.py"
else
warn "patch_logs_center.py не найден, центр журналов пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_network_guard.py" ]]; then
run_logged "VM network guard patch применён" python3 "${REPO_DIR}/scripts/patch_vm_network_guard.py" "${APP_DIR}/app.py"
else
warn "patch_vm_network_guard.py не найден, защита от отсутствующего bridge пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_update_center.py" ]]; then
run_logged "update center patch применён" python3 "${REPO_DIR}/scripts/patch_update_center.py" "${APP_DIR}/app.py"
else
warn "patch_update_center.py не найден, центр обновлений пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_live_status.py" ]]; then
run_logged "live status patch применён" python3 "${REPO_DIR}/scripts/patch_live_status.py" "${APP_DIR}/app.py"
else
warn "patch_live_status.py не найден, live-статусы пропущены"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_autostart.py" ]]; then
run_logged "VM autostart patch применён" python3 "${REPO_DIR}/scripts/patch_vm_autostart.py" "${APP_DIR}/app.py"
else
warn "patch_vm_autostart.py не найден, нормализация автозапуска VM пропущена"
fi
run_logged "Канонические шаблоны панели финально восстановлены" restore_canonical_templates
run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete --exclude='__pycache__/' --exclude='.env' "$WEB_DIR/" "$APP_DIR/"
run_logged "Версия панели записана" install -m 0644 "${REPO_DIR}/VERSION" "${APP_DIR}/VERSION"
run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR"
if [[ -f "$PROFILE_FILE" ]]; then
ok "Профиль уже сохранён: $PROFILE_FILE"
@@ -291,9 +212,20 @@ else
chmod 600 "$SESSION_SECRET_FILE"
ok "Создан постоянный session secret: $SESSION_SECRET_FILE"
fi
cat > "$NODE_CONFIG_FILE" <<EOF
# Virtuality node settings. Edit and re-run: sudo bash scripts/install_web_panel.sh
VIRTUALITY_WEB_PORT=${PORT}
VIRTUALITY_WEB_HOST=${WEB_HOST}
VIRTUALITY_AUTH_USER=${AUTH_USER}
VIRTUALITY_AUTO_UPDATE=${AUTO_UPDATE}
VIRTUALITY_COOKIE_SECURE=${COOKIE_SECURE}
EOF
chmod 644 "$NODE_CONFIG_FILE"
ok "Настройки ноды сохранены: $NODE_CONFIG_FILE"
cat > "${APP_DIR}/.env" <<EOF
VIRTUALITY_AUTH_USER=${AUTH_USER}
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
VIRTUALITY_COOKIE_SECURE=${COOKIE_SECURE}
TMPDIR=${UPLOAD_TMP_DIR}
TEMP=${UPLOAD_TMP_DIR}
TMP=${UPLOAD_TMP_DIR}
@@ -304,15 +236,24 @@ ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
step "Создаём Python virtualenv"
if [[ -d "$VENV_DIR" ]]; then
if [[ -x "$VENV_DIR/bin/python" ]] && "$VENV_DIR/bin/python" -c 'import sys' >/dev/null 2>&1; then
warn "Virtualenv уже существует, будет переиспользован: $VENV_DIR"
else
if [[ -d "$VENV_DIR" ]]; then
warn "Virtualenv повреждён (например, после обновления Python), пересоздаём"
rm -rf -- "${VENV_DIR:?}"
fi
run_logged "Virtualenv создан: $VENV_DIR" python3 -m venv "$VENV_DIR"
fi
step "Устанавливаем Python-зависимости"
run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
if compgen -G "${WHEELS_DIR}/*.whl" >/dev/null; then
run_logged "Python-зависимости установлены офлайн из ${WHEELS_DIR}" "$VENV_DIR/bin/pip" install --no-index --find-links "$WHEELS_DIR" -r "$APP_DIR/requirements.txt"
else
run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
fi
run_logged "Web-панель импортируется без ошибок" bash -c "cd '$APP_DIR' && '$VENV_DIR/bin/python' -c 'import app'"
step "Создаём systemd service"
cat > "$SERVICE_FILE" <<EOF
@@ -324,9 +265,11 @@ Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host 0.0.0.0 --port ${PORT}
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host ${WEB_HOST} --port ${PORT} --workers 1 --no-server-header
Restart=always
RestartSec=3
TimeoutStopSec=20
LimitNOFILE=65536
User=root
Group=root
Environment=PYTHONUNBUFFERED=1
@@ -340,7 +283,24 @@ Environment=TMP=${UPLOAD_TMP_DIR}
WantedBy=multi-user.target
EOF
ok "Создан service: $SERVICE_FILE"
cat > "$NETWORK_SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality NAT port forwarding restore
After=network-online.target libvirtd.service nftables.service ufw.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/restore_network.py
[Install]
WantedBy=multi-user.target
EOF
ok "Создан service: $NETWORK_SERVICE_FILE"
run_logged "systemd daemon-reload выполнен" systemctl daemon-reload
run_logged "virtuality-network.service включён" systemctl enable virtuality-network.service
step "Настраиваем автообновление"
if [[ -f "${REPO_DIR}/scripts/auto_update_check.sh" ]]; then
@@ -373,8 +333,13 @@ Unit=virtuality-auto-update.service
WantedBy=timers.target
EOF
run_logged "systemd daemon-reload выполнен для автообновлений" systemctl daemon-reload
run_logged "virtuality-auto-update.timer включён" systemctl enable --now virtuality-auto-update.timer
ok "Автообновление будет проверять GitHub 1 раз в сутки в 00:00 по Москве"
if [[ "$AUTO_UPDATE" == "1" ]]; then
run_logged "virtuality-auto-update.timer включён" systemctl enable --now virtuality-auto-update.timer
ok "Автообновление будет проверять GitHub 1 раз в сутки в 00:00 по Москве"
else
systemctl disable --now virtuality-auto-update.timer >> "$LOG_FILE" 2>&1 || true
ok "Автообновление отключено (VIRTUALITY_AUTO_UPDATE=0). Обновления — вручную через /update"
fi
else
warn "auto_update_check.sh не найден, автообновление пропущено"
fi
@@ -415,7 +380,8 @@ echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-польз
echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}"
echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}"
echo -e "${BOLD}Service:${RESET} virtuality-web.service"
echo -e "${BOLD}Auto update:${RESET} virtuality-auto-update.timer / ежедневно в 00:00 по Москве"
echo -e "${BOLD}Auto update:${RESET} $([[ "$AUTO_UPDATE" == "1" ]] && echo "ежедневно в 00:00 по Москве" || echo "отключено")"
echo -e "${BOLD}Settings:${RESET} ${NODE_CONFIG_FILE}"
echo -e "${BOLD}Upload tmp:${RESET} ${UPLOAD_TMP_DIR}"
echo -e "${BOLD}Session key:${RESET} ${SESSION_SECRET_FILE}"
echo -e "${BOLD}Status:${RESET} systemctl status virtuality-web --no-pager"
-210
Просмотреть файл
@@ -1,210 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helpers = r'''
def is_x86_host(profile: dict[str, Any]) -> bool:
return str(profile.get("arch", "")) in ("x86_64", "amd64")
def is_arm64_guest_on_x86(guest_arch: str, profile: dict[str, Any]) -> bool:
return guest_arch == "aarch64" and is_x86_host(profile)
def available_memory_mib() -> int:
try:
for line in Path('/proc/meminfo').read_text().splitlines():
if line.startswith('MemAvailable:'):
return int(line.split()[1]) // 1024
except Exception:
return 0
return 0
def arm64_emulation_memory_error(memory: int) -> str | None:
available = available_memory_mib()
requested = int(memory)
reserve = 768
if available <= 0:
return None
if requested + reserve <= available:
return None
recommended = max(512, available - reserve)
return (
f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, "
f"доступно около {available} MB, нужен запас минимум {reserve} MB. "
f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте."
)
def xml_escape(value: str) -> str:
return str(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def arm64_emulation_xml(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str:
if network_mode == "nat":
interface_xml = f"""
<interface type='network'>
<source network='{xml_escape(network_core.NETWORK_NAME)}'/>
<model type='virtio'/>
</interface>"""
else:
interface_xml = f"""
<interface type='bridge'>
<source bridge='{xml_escape(bridge)}'/>
<model type='virtio'/>
</interface>"""
return f"""<domain type='qemu'>
<name>{xml_escape(name)}</name>
<memory unit='MiB'>{int(memory)}</memory>
<currentMemory unit='MiB'>{int(memory)}</currentMemory>
<vcpu placement='static'>{int(vcpus)}</vcpu>
<os>
<type arch='aarch64' machine='virt'>hvm</type>
</os>
<cpu mode='custom' match='exact'>
<model fallback='allow'>cortex-a57</model>
</cpu>
<features>
<gic version='3'/>
</features>
<clock offset='utc'/>
<on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot>
<on_crash>restart</on_crash>
<devices>
<emulator>/usr/bin/qemu-system-aarch64</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='{xml_escape(str(disk_path))}'/>
<target dev='vda' bus='virtio'/>
</disk>{interface_xml}
<graphics type='vnc' port='-1' autoport='yes' listen='0.0.0.0'>
<listen type='address' address='0.0.0.0'/>
</graphics>
<video>
<model type='virtio'/>
</video>
<console type='pty'>
<target type='serial' port='0'/>
</console>
</devices>
</domain>
"""
def make_arm64_emulation_script(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str:
xml_path = Path('/tmp') / f"virtuality-{name}-arm64.xml"
xml_path.write_text(arm64_emulation_xml(name, memory, vcpus, disk_path, network_mode, bridge))
return f"virsh define {xml_path} && virsh start {name}"
'''
if 'def arm64_emulation_xml(' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker not in text:
raise SystemExit('valid_vm_name marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('ARM64 emulation XML helpers added')
else:
if 'def arm64_emulation_memory_error(' not in text:
marker = '\n\ndef xml_escape(value: str) -> str:'
if marker not in text:
raise SystemExit('xml_escape marker not found')
memory_helpers = r'''
def available_memory_mib() -> int:
try:
for line in Path('/proc/meminfo').read_text().splitlines():
if line.startswith('MemAvailable:'):
return int(line.split()[1]) // 1024
except Exception:
return 0
return 0
def arm64_emulation_memory_error(memory: int) -> str | None:
available = available_memory_mib()
requested = int(memory)
reserve = 768
if available <= 0:
return None
if requested + reserve <= available:
return None
recommended = max(512, available - reserve)
return (
f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, "
f"доступно около {available} MB, нужен запас минимум {reserve} MB. "
f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте."
)
'''
text = text.replace(marker, memory_helpers + marker, 1)
changed.append('ARM64 memory guard helpers added')
else:
changed.append('ARM64 memory guard helpers already present')
changed.append('ARM64 emulation XML helpers already present')
old_disk_cmd = ''' if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
new_disk_cmd = ''' if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
if is_arm64_guest_on_x86(resolved_guest_arch, profile):
memory_error = arm64_emulation_memory_error(memory)
if memory_error:
return vm_form_context(request, error=memory_error, form=form, status_code=400)
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
if is_arm64_guest_on_x86(resolved_guest_arch, profile):
return vm_form_context(request, error="ARM64 ISO на x86-хосте пока не поддерживается. Загрузи готовый ARM64 .img/.qcow2 в разделе «Диски» и создай VM из готового диска.", form=form, status_code=400)
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
old_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile):
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
'''
new_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile):
memory_error = arm64_emulation_memory_error(memory)
if memory_error:
return vm_form_context(request, error=memory_error, form=form, status_code=400)
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
'''
if old_disk_cmd in text:
text = text.replace(old_disk_cmd, new_disk_cmd, 1)
changed.append('ARM64 disk image on x86 uses direct libvirt XML with memory guard')
elif old_arm64_xml_cmd in text:
text = text.replace(old_arm64_xml_cmd, new_arm64_xml_cmd, 1)
changed.append('ARM64 memory guard added to existing XML path')
elif 'memory_error = arm64_emulation_memory_error(memory)' in text:
changed.append('ARM64 memory guard already present')
elif 'make_arm64_emulation_script' in text:
changed.append('ARM64 disk image XML path already present')
else:
raise SystemExit('disk image create command marker not found')
app_path.write_text(text)
print('arm64 emulation xml patch applied:')
for item in changed:
print(f'- {item}')
-271
Просмотреть файл
@@ -1,271 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
changed: list[str] = []
warnings: list[str] = []
if not app_path.exists():
print(f'WARN: app.py not found: {app_path}')
raise SystemExit(0)
templates_dir = app_path.parent / 'templates'
dashboard_template_path = templates_dir / 'dashboard.html'
network_template_path = templates_dir / 'network.html'
if not dashboard_template_path.exists():
print(f'WARN: dashboard.html not found: {dashboard_template_path}')
raise SystemExit(0)
text = app_path.read_text(encoding='utf-8')
text, removed_helpers = re.subn(
r"\n\ndef parse_dhcp_leases_output\(output: str\).*?\n\ndef parse_virsh_list\(\) -> list\[dict\[str, str\]\]:",
"\n\ndef parse_virsh_list() -> list[dict[str, str]]:",
text,
count=1,
flags=re.S,
)
if removed_helpers:
changed.append('old VM IP helper block was removed')
new_parse_virsh_list = '''def parse_virsh_list() -> list[dict[str, str]]:
def clean_ip(ip: str) -> str:
ip = (ip or "").split("/")[0].strip()
if not ip or ip.startswith("127.") or ip.startswith("169.254.") or ip == "0.0.0.0":
return ""
return ip
def manual_ip_map() -> dict[str, str]:
path = Path("/var/lib/virtuality/network/vm_ips.json")
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {str(k): clean_ip(str(v)) for k, v in data.items() if clean_ip(str(v))}
except Exception:
pass
return {}
def vm_macs(name: str) -> list[str]:
try:
result = run_cmd(["virsh", "domiflist", name], timeout=8)
if not result.get("ok"):
return []
return [mac.lower() for mac in re.findall(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", result.get("stdout", ""))]
except Exception:
return []
def ip_from_domifaddr(name: str) -> str:
try:
result = run_cmd(["virsh", "domifaddr", name], timeout=8)
if result.get("ok"):
for ip in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})/\\d+", result.get("stdout", "")):
ip = clean_ip(ip)
if ip:
return ip
except Exception:
pass
return ""
def ip_from_network_core(name: str) -> str:
try:
resolved = network_core.resolve_vm_ip(name)
if resolved:
return clean_ip(str(resolved))
except Exception:
pass
return ""
def ip_from_dnsmasq_leases(macs: list[str]) -> str:
if not macs:
return ""
try:
for lease_file in Path("/var/lib/libvirt/dnsmasq").glob("*.leases"):
for line in lease_file.read_text(errors="ignore").splitlines():
low = line.lower()
if not any(mac in low for mac in macs):
continue
parts = line.split()
if len(parts) >= 3:
ip = clean_ip(parts[2])
if ip:
return ip
except Exception:
pass
return ""
def ip_from_neighbor_tables(macs: list[str]) -> str:
if not macs:
return ""
commands = [["ip", "neigh", "show"], ["ip", "neigh", "show", "dev", "virbr100"], ["ip", "neigh", "show", "dev", "br0"], ["arp", "-an"]]
for cmd in commands:
try:
result = run_cmd(cmd, timeout=8)
if not result.get("ok"):
continue
for line in result.get("stdout", "").splitlines():
low = line.lower()
if not any(mac in low for mac in macs):
continue
for value in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})\\b", line):
ip = clean_ip(value)
if ip:
return ip
except Exception:
pass
return ""
manual_ips = manual_ip_map()
def resolve_ip(name: str) -> str:
if not name:
return "—"
if manual_ips.get(name):
return manual_ips[name]
macs = vm_macs(name)
for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)):
try:
ip = resolver()
if ip:
return ip
except Exception:
pass
return "—"
result = run_cmd(["virsh", "list", "--all"])
rows = []
if not result["ok"]:
return rows
for line in result["stdout"].splitlines()[2:]:
parts = line.strip().split(None, 2)
if len(parts) == 3:
rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
elif len(parts) == 2:
rows.append({"id": "-", "name": parts[0], "state": parts[1]})
for row in rows:
row["ip"] = resolve_ip(row.get("name", ""))
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
return rows
'''
pattern = r"def parse_virsh_list\(\) -> list\[dict\[str, str\]\]:.*?\n\ndef parse_pool_list\(\) -> list\[dict\[str, str\]\]:"
replacement = new_parse_virsh_list + "\n\ndef parse_pool_list() -> list[dict[str, str]]:"
text, replaced = re.subn(pattern, lambda _match: replacement, text, count=1, flags=re.S)
if replaced:
changed.append('parse_virsh_list was replaced with manual-first VM IP resolver')
else:
warnings.append('parse_virsh_list block not found, app.py IP injection skipped')
manual_route = '''
@app.post("/network/vm-ip/save")
def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
if not valid_vm_name(vm_name):
return RedirectResponse(url="/network", status_code=303)
manual_ip = (manual_ip or "").strip()
if manual_ip and not re.fullmatch(r"(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}", manual_ip):
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400)
path = Path("/var/lib/virtuality/network/vm_ips.json")
path.parent.mkdir(parents=True, exist_ok=True)
try:
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
if manual_ip:
data[vm_name] = manual_ip
else:
data.pop(vm_name, None)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
return RedirectResponse(url="/network", status_code=303)
'''
if '/network/vm-ip/save' not in text:
marker = '\n\n@app.post("/network/nat/setup")'
if marker in text:
text = text.replace(marker, manual_route + marker, 1)
changed.append('manual VM IP route was added')
else:
warnings.append('network NAT route marker not found, manual IP route skipped')
else:
changed.append('manual VM IP route was already present')
app_path.write_text(text, encoding='utf-8')
dashboard_html = dashboard_template_path.read_text(encoding='utf-8')
header_old = '<tr><th>ID</th><th>Name</th><th>State</th><th>Actions</th></tr>'
header_new = '<tr><th>ID</th><th>Name</th><th>IP</th><th>State</th><th>Actions</th></tr>'
name_cell = '''<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
<td><span'''
name_ip_cell = '''<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
<td class="strong">{{ vm.ip|default("—") }}</td>
<td><span'''
if '<th>IP</th>' not in dashboard_html:
dashboard_html = dashboard_html.replace(header_old, header_new).replace(name_cell, name_ip_cell).replace('colspan="4" class="muted">Виртуальных машин пока нет', 'colspan="5" class="muted">Виртуальных машин пока нет')
changed.append('Dashboard VM IP column was added')
elif 'vm.ip' not in dashboard_html:
dashboard_html = dashboard_html.replace(name_cell, name_ip_cell)
changed.append('Dashboard VM IP cell was added')
else:
changed.append('Dashboard VM IP column was already present')
dashboard_template_path.write_text(dashboard_html, encoding='utf-8')
if network_template_path.exists():
network_html = network_template_path.read_text(encoding='utf-8')
manual_card = '''
<article class="card">
<div class="card-head">
<h2>Ручные IP VM</h2>
<span class="pill">override</span>
</div>
<div class="notice">Если VM в bridge/static-сети и IP не виден через DHCP/ARP, укажи адрес вручную. Этот IP будет первым источником для таблицы и проброса портов.</div>
<table class="top-space">
<thead><tr><th>VM</th><th>Текущий IP</th><th>Ручной IP</th><th>Действие</th></tr></thead>
<tbody>
{% for vm in vms %}
<tr>
<td class="strong">{{ vm.name }}</td>
<td>{{ vm.ip|default("—") }}</td>
<td>
<form method="post" action="/network/vm-ip/save" class="inline-form">
<input type="hidden" name="vm_name" value="{{ vm.name }}">
<input type="text" name="manual_ip" value="{{ vm.manual_ip|default("") }}" placeholder="например 10.0.0.50" pattern="(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])){3}">
</td>
<td><button>Сохранить</button></form></td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">VM пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</article>
'''
if 'Ручные IP VM' not in network_html:
marker = ' <article class="card">\n <div class="card-head">\n <h2>Диагностика публичного доступа</h2>'
if marker in network_html:
network_html = network_html.replace(marker, manual_card + '\n' + marker, 1)
changed.append('manual VM IP card was added to network page')
else:
warnings.append('network diagnostics card marker not found, manual IP card skipped')
else:
changed.append('manual VM IP card was already present')
network_template_path.write_text(network_html, encoding='utf-8')
else:
warnings.append('network.html not found, manual IP card skipped')
print('DHCP leases empty-state patch completed:')
for item in changed:
print(f'- {item}')
for item in warnings:
print(f'WARN: {item}')
raise SystemExit(0)
-316
Просмотреть файл
@@ -1,316 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import zipfile' not in text:
text = text.replace('import uuid\n', 'import uuid\nimport lzma\nimport tarfile\nimport zipfile\n', 1)
changed.append('archive imports added')
elif 'import lzma' not in text:
text = text.replace('import tarfile\n', 'import lzma\nimport tarfile\n', 1) if 'import tarfile\n' in text else text.replace('import zipfile\n', 'import lzma\nimport zipfile\n', 1)
changed.append('lzma import added')
else:
changed.append('archive imports already present')
helpers = r'''
def safe_disk_upload_filename(filename: str) -> str | None:
original = Path(filename or "").name.strip()
lower = original.lower()
if lower.endswith(".tar.gz"):
suffix = ".tar.gz"
stem = original[:-7]
elif lower.endswith(".img.xz"):
suffix = ".img.xz"
stem = original[:-7]
elif lower.endswith(".tgz"):
suffix = ".tgz"
stem = original[:-4]
else:
suffix = Path(original).suffix.lower()
stem = Path(original).stem
if suffix not in (".img", ".raw", ".qcow2", ".img.xz", ".zip", ".tar.gz", ".tgz"):
return None
stem = re.sub(r"\s+", "-", stem.strip())
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
stem = stem.strip("._-")[:120]
if not stem:
stem = "virtuality-disk"
return f"{stem}{suffix}"
def disk_upload_is_archive(name: str) -> bool:
lower = str(name or "").lower()
return lower.endswith((".zip", ".tar.gz", ".tgz"))
def disk_upload_is_xz_image(name: str) -> bool:
return str(name or "").lower().endswith(".img.xz")
def disk_upload_is_image(name: str) -> bool:
lower = str(name or "").lower()
return lower.endswith((".img", ".raw", ".qcow2", ".img.xz"))
def archive_member_is_safe(name: str) -> bool:
if not name:
return False
p = Path(name)
if p.is_absolute():
return False
return ".." not in p.parts
def archive_member_basename(name: str) -> str | None:
return safe_disk_image_filename(Path(name).name)
def unique_disk_image_path(name: str) -> Path:
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
safe = safe_disk_image_filename(name)
if not safe:
raise ValueError("Некорректное имя образа диска")
target = DISK_IMAGES_DIR / safe
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
for index in range(1, 1000):
candidate = DISK_IMAGES_DIR / f"{stem}-{index}{suffix}"
if not candidate.exists():
return candidate
raise ValueError("Не удалось подобрать свободное имя файла")
def find_archive_disk_members(archive_path: Path) -> list[dict[str, Any]]:
members: list[dict[str, Any]] = []
lower = archive_path.name.lower()
if lower.endswith(".zip"):
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
if info.is_dir() or not archive_member_is_safe(info.filename):
continue
safe_name = archive_member_basename(info.filename)
if safe_name:
members.append({"kind": "zip", "name": info.filename, "safe_name": safe_name, "size": int(info.file_size)})
elif lower.endswith((".tar.gz", ".tgz")):
with tarfile.open(archive_path, "r:gz") as archive:
for info in archive.getmembers():
if not info.isfile() or not archive_member_is_safe(info.name):
continue
safe_name = archive_member_basename(info.name)
if safe_name:
members.append({"kind": "tar", "name": info.name, "safe_name": safe_name, "size": int(info.size)})
else:
raise ValueError("Поддерживаются только .zip, .tar.gz и .tgz")
return sorted(members, key=lambda item: item.get("size", 0), reverse=True)
def extract_disk_archive(archive_path: Path) -> list[Path]:
members = find_archive_disk_members(archive_path)
if not members:
raise ValueError("В архиве не найдено .img, .raw или .qcow2 файлов")
extracted: list[Path] = []
lower = archive_path.name.lower()
if lower.endswith(".zip"):
with zipfile.ZipFile(archive_path) as archive:
for member in members:
target = unique_disk_image_path(member["safe_name"])
with archive.open(member["name"], "r") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
extracted.append(target)
else:
with tarfile.open(archive_path, "r:gz") as archive:
for member in members:
file_obj = archive.extractfile(member["name"])
if file_obj is None:
continue
target = unique_disk_image_path(member["safe_name"])
with file_obj as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
extracted.append(target)
return extracted
def extract_xz_disk_image(compressed_path: Path, safe_name: str | None = None) -> Path:
source_name = safe_name or compressed_path.name
if not source_name.lower().endswith(".img.xz"):
raise ValueError("Поддерживаются только сжатые образы .img.xz")
raw_name = source_name[:-3]
target = unique_disk_image_path(raw_name)
try:
with lzma.open(compressed_path, "rb") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
except Exception:
target.unlink(missing_ok=True)
raise
return target
def disk_convert_target_path(source_path: Path) -> Path:
if source_path.suffix.lower() == ".qcow2":
return source_path
target = source_path.with_suffix(".qcow2")
if not target.exists():
return target
for index in range(1, 1000):
candidate = source_path.with_name(f"{source_path.stem}-{index}.qcow2")
if not candidate.exists():
return candidate
raise ValueError("Не удалось подобрать имя qcow2 для конвертации")
def disk_convert_progress(line: str, current: int) -> int:
match = re.search(r"\((\d+(?:\.\d+)?)/100%\)", line or "")
if match:
return max(current, int(float(match.group(1))))
match = re.search(r"(\d+(?:\.\d+)?)%", line or "")
if match:
return max(current, int(float(match.group(1))))
return current
def run_disk_convert_worker(operation_id: str, source_path: str, target_path: str) -> None:
operation = read_operation(operation_id)
if not operation:
return
source = Path(source_path)
target = Path(target_path)
update_operation(operation, status="running", progress=1, message=f"Конвертация {source.name} в qcow2", started_at=utc_now())
cmd = ["qemu-img", "convert", "-p", "-f", disk_image_format(source), "-O", "qcow2", str(source), str(target)]
append_operation_log(operation_id, "Запуск конвертации:")
append_operation_log(operation_id, " ".join(cmd))
try:
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
progress = 1
if process.stdout:
for line in process.stdout:
append_operation_log(operation_id, line)
progress = disk_convert_progress(line, progress)
fresh = read_operation(operation_id) or operation
update_operation(fresh, progress=progress, message=f"Конвертация {source.name}: {progress}%")
exit_code = process.wait()
fresh = read_operation(operation_id) or operation
if exit_code == 0:
append_operation_log(operation_id, f"Конвертация завершена: {target}")
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=f"Готово: {target.name}", finished_at=utc_now(), target_path=str(target))
else:
append_operation_log(operation_id, f"qemu-img завершился с ошибкой. Exit code: {exit_code}")
target.unlink(missing_ok=True)
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"qemu-img завершился с ошибкой: {exit_code}", finished_at=utc_now())
except Exception as exc:
fresh = read_operation(operation_id) or operation
append_operation_log(operation_id, f"Ошибка конвертации: {exc}")
target.unlink(missing_ok=True)
update_operation(fresh, status="error", progress=100, exit_code=-1, message=str(exc), finished_at=utc_now())
def start_disk_convert_operation(source_path: Path) -> dict[str, Any] | None:
if source_path.suffix.lower() == ".qcow2":
return None
target_path = disk_convert_target_path(source_path)
operation_id = str(uuid.uuid4())
operation = {
"id": operation_id,
"type": "disk_convert",
"title": f"Конвертация {source_path.name}",
"status": "queued",
"progress": 0,
"message": "Конвертация поставлена в очередь",
"created_at": utc_now(),
"updated_at": utc_now(),
"created_by": AUTH_USER,
"source_path": str(source_path),
"target_path": str(target_path),
}
write_operation(operation)
append_operation_log(operation_id, "Операция поставлена в очередь.")
threading.Thread(target=run_disk_convert_worker, args=(operation_id, str(source_path), str(target_path)), daemon=True).start()
return operation
def disk_upload_response(request: Request, payload: dict[str, Any]):
if request.headers.get("x-requested-with") == "XMLHttpRequest" or "application/json" in request.headers.get("accept", ""):
return JSONResponse(payload)
return RedirectResponse(url="/disk-images", status_code=303)
'''
if 'def safe_disk_upload_filename(' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker not in text:
marker = '\n\ndef bridge_exists(name: str) -> bool:'
if marker not in text:
raise SystemExit('helper insert marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('disk archive helpers added')
else:
pattern = r"\n\ndef safe_disk_upload_filename\(filename: str\).*?\n\ndef disk_upload_response\(request: Request, payload: dict\[str, Any\]\):.*?\n return RedirectResponse\(url=\"/disk-images\", status_code=303\)\n"
text, count = re.subn(pattern, helpers, text, count=1, flags=re.S)
changed.append('disk archive helpers replaced with img.xz support' if count else 'disk archive helpers already present')
old_route_start = text.find('@app.post("/disk-images/upload"')
if old_route_start == -1:
raise SystemExit('disk image upload route not found')
next_route = text.find('\n\n@app.post("/disk-images/{name}/delete")', old_route_start)
if next_route == -1:
raise SystemExit('disk image delete route marker not found')
new_route = r'''@app.post("/disk-images/upload", response_class=HTMLResponse)
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_upload_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
saved_paths: list[Path] = []
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out, length=1024 * 1024)
if disk_upload_is_archive(safe_name):
saved_paths = extract_disk_archive(tmp_target)
tmp_target.unlink(missing_ok=True)
elif disk_upload_is_xz_image(safe_name):
saved_paths = [extract_xz_disk_image(tmp_target, safe_name)]
tmp_target.unlink(missing_ok=True)
else:
target = unique_disk_image_path(safe_name)
tmp_target.rename(target)
saved_paths = [target]
except Exception as exc:
tmp_target.unlink(missing_ok=True)
for path in saved_paths:
path.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500)
operations = []
for path in saved_paths:
operation = start_disk_convert_operation(path)
if operation:
operations.append(operation)
payload = {
"ok": True,
"mode": "converting" if operations else "ready",
"operation_id": operations[0]["id"] if operations else None,
"operation_ids": [op["id"] for op in operations],
"files": [path.name for path in saved_paths],
"message": f"Загружено файлов: {len(saved_paths)}. Конвертаций запущено: {len(operations)}.",
}
return disk_upload_response(request, payload)
'''
text = text[:old_route_start] + new_route + text[next_route:]
changed.append('disk image upload route supports img.xz and archives')
app_path.write_text(text)
print('disk archive import patch applied:')
for item in changed:
print(f'- {item}')
-171
Просмотреть файл
@@ -1,171 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
print(f'WARN: app.py not found: {app_path}')
raise SystemExit(0)
text = app_path.read_text()
changed = []
warnings = []
old_progress = '''def progress_from_line(current: int, line: str) -> int:
low = line.lower()
if "allocating" in low or "creating storage" in low:
return max(current, 30)
if "starting install" in low or "installing" in low:
return max(current, 50)
if "creating domain" in low:
return max(current, 75)
if "domain creation completed" in low or "installation continues" in low:
return max(current, 90)
return current
'''
new_progress = '''def progress_from_line(current: int, line: str) -> int:
low = line.lower()
convert_match = re.search(r"\\((\\d+(?:\\.\\d+)?)\\s*/\\s*100%\\)", line)
if convert_match:
return max(1, min(99, int(float(convert_match.group(1)))))
percent_match = re.search(r"(\\d+(?:\\.\\d+)?)%", line)
if "qemu-img" in low and percent_match:
return max(1, min(99, int(float(percent_match.group(1)))))
if "allocating" in low or "creating storage" in low:
return max(current, 30)
if "starting install" in low or "installing" in low:
return max(current, 50)
if "creating domain" in low:
return max(current, 75)
if "domain creation completed" in low or "installation continues" in low:
return max(current, 90)
return current
'''
if old_progress in text:
text = text.replace(old_progress, new_progress, 1)
changed.append('qemu-img percent parser added')
elif 'convert_match = re.search' in text:
changed.append('qemu-img percent parser already present')
else:
warnings.append('progress_from_line marker not found, progress parser skipped')
helpers = r'''
def qemu_img_format_for_path(path: Path) -> str:
suffix = path.suffix.lower()
if suffix == ".qcow2":
return "qcow2"
return "raw"
def qcow2_name_for_upload(safe_name: str) -> str:
base = Path(safe_name).stem
return f"{base}.qcow2"
def start_disk_image_convert_operation(source_path: Path, target_path: Path, source_format: str) -> dict[str, Any]:
operation_id = str(uuid.uuid4())
cmd = ["qemu-img", "convert", "-p", "-f", source_format, "-O", "qcow2", str(source_path), str(target_path)]
operation = {
"id": operation_id,
"type": "disk_image_convert",
"title": f"Конвертация образа {source_path.name}",
"status": "queued",
"progress": 0,
"message": "Ожидание конвертации образа",
"created_at": utc_now(),
"updated_at": utc_now(),
"created_by": AUTH_USER,
"source_path": str(source_path),
"target_path": str(target_path),
"source_format": source_format,
"cmd": " ".join(cmd),
}
start_background_operation(operation, cmd)
return operation
'''
if 'def start_disk_image_convert_operation(' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('disk conversion operation helpers added')
else:
warnings.append('valid_vm_name marker not found, conversion helpers skipped')
else:
changed.append('disk conversion operation helpers already present')
old_upload = r'''@app.post("/disk-images/upload", response_class=HTMLResponse)
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_image_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
target = DISK_IMAGES_DIR / safe_name
if target.exists():
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Образ уже существует: {safe_name}"}, status_code=400)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out)
tmp_target.rename(target)
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки образа: {exc}"}, status_code=500)
return RedirectResponse(url="/disk-images", status_code=303)
'''
new_upload = r'''@app.post("/disk-images/upload")
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_image_filename(image_file.filename or "")
wants_json = "application/json" in request.headers.get("accept", "") or request.headers.get("x-requested-with") == "XMLHttpRequest"
def disk_error(message: str, status_code: int = 400):
if wants_json:
return JSONResponse({"ok": False, "error": message}, status_code=status_code)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": message}, status_code=status_code)
if not safe_name:
return disk_error("Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем.")
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
target = DISK_IMAGES_DIR / safe_name
final_qcow2 = DISK_IMAGES_DIR / qcow2_name_for_upload(safe_name)
if target.exists() or final_qcow2.exists():
return disk_error(f"Образ уже существует: {safe_name}")
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out)
tmp_target.rename(target)
if target.suffix.lower() == ".qcow2":
payload = {"ok": True, "mode": "ready", "name": target.name, "redirect": "/disk-images"}
else:
operation = start_disk_image_convert_operation(target, final_qcow2, qemu_img_format_for_path(target))
payload = {"ok": True, "mode": "converting", "name": target.name, "target": final_qcow2.name, "operation_id": operation["id"], "redirect": "/disk-images"}
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return disk_error(f"Ошибка загрузки образа: {exc}", status_code=500)
if wants_json:
return JSONResponse(payload)
return RedirectResponse(url="/disk-images", status_code=303)
'''
if old_upload in text:
text = text.replace(old_upload, new_upload, 1)
changed.append('disk upload returns JSON and starts conversion operation')
elif 'mode": "converting"' in text and ('start_disk_image_convert_operation' in text or 'start_disk_convert_operation' in text):
changed.append('disk upload conversion already present')
else:
warnings.append('disk_image_upload marker not found, upload conversion patch skipped')
app_path.write_text(text)
print('disk convert progress patch completed:')
for item in changed:
print(f'- {item}')
for item in warnings:
print(f'WARN: {item}')
raise SystemExit(0)
-298
Просмотреть файл
@@ -1,298 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
warnings = []
if 'DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")' not in text:
text = text.replace('IMAGES_DIR = Path("/var/lib/virtuality/images")\n', 'IMAGES_DIR = Path("/var/lib/virtuality/images")\nDISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")\n', 1)
changed.append('DISK_IMAGES_DIR added')
else:
changed.append('DISK_IMAGES_DIR already present')
helpers = r'''
def vm_arch_options() -> list[dict[str, str]]:
return [
{"value": "auto", "label": "Auto — по профилю хоста"},
{"value": "x86_64", "label": "x86_64 / amd64"},
{"value": "aarch64", "label": "ARM64 / aarch64"},
{"value": "generic", "label": "Generic / no arch override"},
]
def normalize_guest_arch(value: str, profile: dict[str, Any]) -> str:
value = (value or "auto").strip()
if value == "auto":
return str(profile.get("recommended_guest_arch") or "x86_64")
if value in ("x86_64", "amd64"):
return "x86_64"
if value in ("aarch64", "arm64"):
return "aarch64"
if value == "generic":
return "generic"
return str(profile.get("recommended_guest_arch") or "x86_64")
def list_disk_image_files() -> list[dict[str, str]]:
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
files = []
for item in sorted(list(DISK_IMAGES_DIR.glob("*.img")) + list(DISK_IMAGES_DIR.glob("*.raw")) + list(DISK_IMAGES_DIR.glob("*.qcow2"))):
try:
stat = item.stat()
size_gb = stat.st_size / 1024 / 1024 / 1024
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
except OSError:
size_gb = 0
updated = "unknown"
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated})
return files
def safe_disk_image_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith((".img", ".raw", ".qcow2")):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE):
return None
return name
def disk_image_path_by_name(name: str) -> Path | None:
safe_name = safe_disk_image_filename(name)
if not safe_name:
return None
path = (DISK_IMAGES_DIR / safe_name).resolve()
if DISK_IMAGES_DIR.resolve() not in path.parents:
return None
return path
def disk_image_format(path: Path) -> str:
suffix = path.suffix.lower().lstrip('.')
if suffix == 'qcow2':
return 'qcow2'
return 'raw'
def bridge_exists(name: str) -> bool:
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
return False
return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]
'''
if 'def list_disk_image_files() -> list[dict[str, str]]:' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker not in text:
raise SystemExit('valid_vm_name marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('disk image and architecture helpers added')
else:
if 'def vm_arch_options()' not in text:
insert = helpers.split('\n\ndef list_disk_image_files()', 1)[0]
marker = '\n\ndef list_disk_image_files() -> list[dict[str, str]]:'
text = text.replace(marker, insert + marker, 1)
changed.append('architecture helpers added')
else:
changed.append('architecture helpers already present')
changed.append('disk image helpers already present')
text = text.replace('\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n', '\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n')
old_context = '"isos": list_iso_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context = '"isos": list_iso_files(), "disk_images": list_disk_image_files(), "arch_options": vm_arch_options(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context in text:
text = text.replace(old_context, new_context, 1)
changed.append('vm form context gets disk images and arch options')
elif '"disk_images": list_disk_image_files()' in text and '"arch_options": vm_arch_options()' not in text:
text = text.replace('"disk_images": list_disk_image_files(),', '"disk_images": list_disk_image_files(), "arch_options": vm_arch_options(),')
text = text.replace('"source_type": "iso", "network_mode"', '"source_type": "iso", "guest_arch": "auto", "network_mode"')
changed.append('vm form context upgraded with arch options')
elif '"arch_options": vm_arch_options()' in text:
changed.append('vm form context already has arch options')
else:
warnings.append('vm_form_context marker not found')
routes = r'''
@app.get("/disk-images", response_class=HTMLResponse)
def disk_images_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error})
@app.post("/disk-images/upload", response_class=HTMLResponse)
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_image_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
target = DISK_IMAGES_DIR / safe_name
if target.exists():
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Образ уже существует: {safe_name}"}, status_code=400)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out)
tmp_target.rename(target)
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки образа: {exc}"}, status_code=500)
return RedirectResponse(url="/disk-images", status_code=303)
@app.post("/disk-images/{name}/delete")
def disk_image_delete(request: Request, name: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
path = disk_image_path_by_name(name)
if path and path.exists() and path.is_file():
path.unlink()
return RedirectResponse(url="/disk-images", status_code=303)
'''
if '@app.get("/disk-images"' not in text:
marker = '\n\n@app.get("/network", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('network route marker not found')
text = text.replace(marker, routes + marker, 1)
changed.append('disk image routes added')
else:
changed.append('disk image routes already present')
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = '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"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('vm create signature supports disk images and guest arch')
elif 'source_type: str = Form("iso")' in text and 'guest_arch: str = Form("auto")' not in text:
text = text.replace('source_type: str = Form("iso"), network_mode:', 'source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode:', 1)
changed.append('vm create signature upgraded with guest arch')
elif 'guest_arch: str = Form("auto")' in text:
changed.append('vm create signature already supports guest arch')
else:
warnings.append('vm_create_submit signature marker not found')
old_body = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}'
new_body = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_body in text:
text = text.replace(old_body, new_body, 1)
changed.append('vm create form state supports source type and guest arch')
elif '"source_type": source_type' in text and '"guest_arch": guest_arch' not in text:
text = text.replace('"source_type": source_type, "network_mode"', '"source_type": source_type, "guest_arch": guest_arch, "network_mode"')
changed.append('vm create form state upgraded with guest arch')
old_iso_validation = ''' else:
iso = Path(iso_path).resolve()
if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
'''
new_iso_validation = ''' elif source_type not in ("iso", "disk_image"):
error = "Некорректный источник VM."
elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):
error = "Некорректная архитектура VM."
elif network_mode == "bridge" and not bridge_exists(bridge):
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
else:
if source_type == "iso":
iso = Path(iso_path).resolve()
if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
else:
disk_image = Path(disk_image_path).resolve()
if DISK_IMAGES_DIR.resolve() not in disk_image.parents or disk_image.suffix.lower() not in (".img", ".raw", ".qcow2") or not disk_image.exists():
error = "Образ диска должен быть существующим .img, .raw или .qcow2 файлом из /var/lib/virtuality/disk-images."
'''
if old_iso_validation in text:
text = text.replace(old_iso_validation, new_iso_validation, 1)
changed.append('vm create validation supports disk image source and guest arch')
elif 'guest_arch not in' not in text and 'source_type == "iso"' in text:
text = text.replace('elif network_mode not in ("nat", "bridge"):\n error = "Некорректный режим сети."', 'elif network_mode not in ("nat", "bridge"):\n error = "Некорректный режим сети."\n elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."', 1)
changed.append('vm create validation upgraded with guest arch')
elif 'guest_arch not in' in text:
changed.append('vm create validation already supports guest arch')
old_cmd = ''' IMAGES_DIR.mkdir(parents=True, exist_ok=True)
disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
is_arm = profile.get("recommended_guest_arch") == "aarch64"
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
if is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
new_cmd = ''' IMAGES_DIR.mkdir(parents=True, exist_ok=True)
disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
selected_arch = normalize_guest_arch(guest_arch, profile)
host_arch = str(profile.get("arch") or platform.machine() or "")
is_arm = selected_arch == "aarch64"
# ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.
virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--virt-type", virt_type]
if selected_arch == "x86_64":
cmd += ["--arch", "x86_64"]
elif is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]
if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
if old_cmd in text:
text = text.replace(old_cmd, new_cmd, 1)
changed.append('vm create command supports ARM64 qemu fallback, guest arch and disk image import')
elif 'selected_arch = normalize_guest_arch' in text:
# Upgrade existing selected_arch block if it still uses kvm for aarch64 on x86.
text = re.sub(
r'virt_type = "kvm" if profile\.get\("kvm_device"\) else "qemu"',
'host_arch = str(profile.get("arch") or platform.machine() or "")\n # ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.\n virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")',
text,
count=1,
)
changed.append('existing vm create command was upgraded with ARM64 qemu fallback')
elif 'virt_type = "kvm" if profile.get("kvm_device") else "qemu"' in text:
text = text.replace('is_arm = profile.get("recommended_guest_arch") == "aarch64"\n virt_type =', 'selected_arch = normalize_guest_arch(guest_arch, profile)\n host_arch = str(profile.get("arch") or platform.machine() or "")\n is_arm = selected_arch == "aarch64"\n virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else', 1)
text = text.replace('if is_arm:\n cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]', 'if selected_arch == "x86_64":\n cmd += ["--arch", "x86_64"]\n elif is_arm:\n cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]', 1)
changed.append('existing vm create command was upgraded with guest arch and ARM64 qemu fallback')
else:
warnings.append('vm command marker not found')
if '"guest_arch": selected_arch' not in text and '"guest_arch": profile.get("recommended_guest_arch")' in text:
text = text.replace('"guest_arch": profile.get("recommended_guest_arch")', '"guest_arch": selected_arch')
if '"disk_image_path": disk_image_path' not in text:
text = text.replace('"iso_path": iso_path, "host_profile"', '"iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": selected_arch, "host_profile"')
app_path.write_text(text)
print('disk images patch applied:')
for item in changed:
print(f'- {item}')
for item in warnings:
print(f'WARN: {item}')
-224
Просмотреть файл
@@ -1,224 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
warnings = []
text = app_path.read_text()
if 'import tempfile' not in text:
text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1)
changed.append('tempfile import added')
else:
changed.append('tempfile import already present')
if 'import xml.etree.ElementTree as ET' not in text:
text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1)
changed.append('ElementTree import added')
else:
changed.append('ElementTree import already present')
helpers = r'''
def vm_boot_order_label(value: str) -> str:
labels = {item["value"]: item["label"] for item in vm_boot_order_options()}
return labels.get(value or "auto", "Auto — по источнику VM")
def boot_order_to_devs(value: str) -> list[str]:
value = normalize_boot_order(value, "disk_image")
mapping = {
"disk": ["hd"],
"cdrom_disk": ["cdrom", "hd"],
"disk_cdrom": ["hd", "cdrom"],
"network_disk": ["network", "hd"],
}
return mapping.get(value, ["hd"])
def boot_devs_to_order(devs: list[str]) -> str:
clean = [item for item in devs if item in ("hd", "cdrom", "network")]
if clean[:2] == ["cdrom", "hd"]:
return "cdrom_disk"
if clean[:2] == ["hd", "cdrom"]:
return "disk_cdrom"
if clean[:2] == ["network", "hd"]:
return "network_disk"
if clean[:1] == ["hd"]:
return "disk"
return "auto"
def current_vm_boot_order(name: str) -> str:
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
if not result.get("ok"):
return "auto"
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception:
return "auto"
os_node = root.find("os")
if os_node is None:
return "auto"
devs = []
for boot in os_node.findall("boot"):
dev = boot.attrib.get("dev", "").strip()
if dev:
devs.append(dev)
return boot_devs_to_order(devs)
def apply_vm_boot_order(name: str, boot_order: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
if boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):
return False, "Некорректный порядок загрузки VM."
selected = normalize_boot_order(boot_order, "disk_image")
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
if not result.get("ok"):
return False, result.get("stderr") or "Не удалось получить XML VM."
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception as exc:
return False, f"Не удалось разобрать XML VM: {exc}"
os_node = root.find("os")
if os_node is None:
os_node = ET.SubElement(root, "os")
for boot in list(os_node.findall("boot")):
os_node.remove(boot)
insert_at = 0
for idx, child in enumerate(list(os_node)):
if child.tag in ("type", "loader", "nvram", "firmware", "smbios", "bootmenu"):
insert_at = idx + 1
for dev in reversed(boot_order_to_devs(selected)):
boot_node = ET.Element("boot", {"dev": dev})
os_node.insert(insert_at, boot_node)
xml_text = ET.tostring(root, encoding="unicode")
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
handle.write(xml_text)
tmp_name = handle.name
try:
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
finally:
Path(tmp_name).unlink(missing_ok=True)
if not define.get("ok"):
return False, define.get("stderr") or "virsh define завершился ошибкой."
return True, f"Порядок загрузки применён: {vm_boot_order_label(selected)}. Если VM запущена, изменение сработает после перезапуска."
'''
if 'def apply_vm_boot_order(name: str, boot_order: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM boot order helpers added')
else:
warnings.append('vm_details marker not found, helpers skipped')
else:
changed.append('existing VM boot order helpers already present')
old = '"host_ip": system_summary()["ip"]})'
new = '"host_ip": system_summary()["ip"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})'
if '"current_boot_order": current_vm_boot_order(name)' not in text:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets boot order')
else:
warnings.append('vm_detail context marker not found, skipped')
else:
changed.append('vm detail context already has boot order')
route = r'''
@app.post("/vm/{name}/boot-order")
def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("auto")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = apply_vm_boot_order(name, boot_order)
if ok:
return RedirectResponse(url=f"/vm/{name}?boot_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?boot_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/boot-order")' not in text:
marker = '\n\n@app.post("/vm/{name}/{action}")'
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('existing VM boot order route added')
else:
warnings.append('generic vm action marker not found, route skipped')
else:
changed.append('existing VM boot order route already present')
app_path.write_text(text)
if template_path.exists():
tpl = template_path.read_text()
original = tpl
if 'action="/vm/{{ vm.name }}/boot-order"' not in tpl:
block = r'''
{% if boot_message %}
<div class="alert success">{{ boot_message }}</div>
{% endif %}
{% if boot_error %}
<div class="alert danger">{{ boot_error }}</div>
{% endif %}
<section class="card">
<div class="card-head">
<h2>Порядок загрузки</h2>
<span class="pill">boot order</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid">
<label>
<span>Порядок загрузки VM</span>
<select name="boot_order" required>
{% for boot in boot_options %}
<option value="{{ boot.value }}" {% if current_boot_order == boot.value %}selected{% endif %}>{{ boot.label }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" type="submit">Применить порядок загрузки</button>
</form>
<p class="muted small-note">Настройка меняет XML-конфигурацию VM через virsh define. Если машина сейчас запущена, новый порядок загрузки сработает после перезапуска.</p>
</section>
'''
markers = ['\n\n <section class="grid two">', '\n\n <section class="grid two">', '\n\n <section class="card">']
inserted = False
for marker in markers:
if marker in tpl:
tpl = tpl.replace(marker, block + marker, 1)
inserted = True
changed.append('boot order card added to vm_detail.html')
break
if not inserted:
warnings.append('vm detail insertion marker not found, boot order card skipped')
else:
changed.append('boot order card already present in vm_detail.html')
if tpl != original:
template_path.write_text(tpl)
else:
warnings.append(f'vm_detail.html not found: {template_path}')
print('existing VM boot order patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
-280
Просмотреть файл
@@ -1,280 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
text = app_path.read_text()
helpers = r'''
def vm_state(name: str) -> str:
result = run_cmd(["virsh", "domstate", name], timeout=8)
return (result.get("stdout") or "unknown").strip().lower()
def current_vm_iso(name: str) -> str:
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
if not result.get("ok"):
return ""
for line in (result.get("stdout") or "").splitlines():
if ".iso" not in line.lower():
continue
parts = line.split()
if parts:
return parts[-1]
return ""
def detach_vm_iso(name: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
xml = run_cmd(["virsh", "dumpxml", name], timeout=12)
cdrom_targets: list[str] = []
if xml.get("ok"):
try:
root = ET.fromstring(xml.get("stdout") or "")
devices = root.find("devices")
if devices is not None:
for disk in devices.findall("disk"):
if disk.attrib.get("device") != "cdrom":
continue
target = disk.find("target")
dev = target.attrib.get("dev") if target is not None else ""
if dev:
cdrom_targets.append(dev)
except Exception:
pass
if not cdrom_targets:
cdrom_targets = ["sda", "hda", "sdb", "hdc"]
running = vm_state(name) == "running"
errors = []
changed_any = False
for target in cdrom_targets:
commands = []
if running:
commands.append(["virsh", "detach-disk", name, target, "--live"])
commands.append(["virsh", "detach-disk", name, target, "--config"])
for cmd in commands:
result = run_cmd(cmd, timeout=30)
if result.get("ok"):
changed_any = True
elif result.get("stderr"):
errors.append(result.get("stderr"))
if changed_any:
return True, "ISO был отмонтирован."
return False, errors[-1] if errors else "Подключенный ISO не найден."
def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
iso = Path(iso_path or "").resolve()
try:
iso_root = ISO_DIR.resolve()
except Exception:
iso_root = Path("/var/lib/virtuality/iso")
if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
return False, "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
detach_vm_iso(name)
running = vm_state(name) == "running"
base = ["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly"]
if running:
live = run_cmd(base + ["--live"], timeout=30)
if not live.get("ok"):
return False, live.get("stderr") or "Не удалось подключить ISO к запущенной VM."
config = run_cmd(base + ["--config"], timeout=30)
if not config.get("ok"):
return False, config.get("stderr") or "Не удалось сохранить ISO в конфигурации VM."
return True, "ISO был смонтирован в VM. Если гостевая ОС его не увидела сразу, перезагрузи VM или обнови устройства внутри гостевой ОС."
'''
if 'def mount_vm_iso(name: str, iso_path: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM ISO mount helpers added')
else:
changed.append('ISO helpers skipped: vm_details marker not found')
else:
changed.append('existing VM ISO mount helpers already present')
if '"current_iso": current_vm_iso(name)' not in text:
replacements = [
(
'"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})',
'"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
(
'"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})',
'"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
(
'"host_ip": system_summary()["ip"]})',
'"host_ip": system_summary()["ip"], "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
]
for old, new in replacements:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets ISO mount data')
break
else:
changed.append('ISO context skipped: vm detail context marker not found')
else:
changed.append('vm detail context already has ISO mount data')
routes = r'''
@app.post("/vm/{name}/iso/mount")
def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = mount_vm_iso(name, iso_path)
if ok:
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
@app.post("/vm/{name}/iso/unmount")
def vm_iso_unmount_apply(request: Request, name: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = detach_vm_iso(name)
if ok:
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/iso/mount")' not in text:
markers = ['\n\n@app.post("/vm/{name}/{action}")', '\n\n@app.get("/vm/create"', '\n\n@app.get("/operations"']
for marker in markers:
if marker in text:
text = text.replace(marker, routes + marker, 1)
changed.append('existing VM ISO mount routes added')
break
else:
changed.append('ISO routes skipped: safe route marker not found')
else:
changed.append('existing VM ISO mount routes already present')
app_path.write_text(text)
iso_card = r'''
<article class="card">
<div class="card-head">
<h2>ISO-привод</h2>
<span class="pill">cdrom</span>
</div>
{% if current_iso %}
<div class="notice">Сейчас подключен ISO: <b>{{ current_iso }}</b></div>
{% else %}
<div class="muted small-note">ISO сейчас не подключён.</div>
{% endif %}
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid">
<label>
<span>ISO образ</span>
<select name="iso_path" required>
{% for iso in isos %}
<option value="{{ iso.path }}" {% if current_iso == iso.path %}selected{% endif %}>{{ iso.name }} — {{ iso.size }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO в VM</button>
</form>
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');">
<button type="submit" class="ghost wide">Отмонтировать ISO</button>
</form>
{% if not isos %}
<div class="alert danger">ISO-образов пока нет. Сначала загрузи .iso в разделе ISO.</div>
{% endif %}
<p class="muted small-note">Для запущенной VM ISO подключается live и сохраняется в конфигурации. Для выключенной VM ISO будет доступен при следующем старте.</p>
</article>
'''
boot_card = r'''
<article class="card">
<div class="card-head">
<h2>Порядок загрузки</h2>
<span class="pill">drag boot</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form">
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order }}">
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order }}">
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle">☰</span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
<div class="boot-order-item" draggable="true" data-device="hd"><span class="drag-handle">☰</span><div><b>Диск</b><small>Основной qcow2/raw диск VM</small></div></div>
<div class="boot-order-item" draggable="true" data-device="network"><span class="drag-handle">☰</span><div><b>Сеть / PXE</b><small>Загрузка по сети</small></div></div>
</div>
<button class="primary wide" type="submit">Применить порядок загрузки</button>
</form>
<p class="muted small-note">Перетащи нужный источник выше. Для загрузки с ISO поставь ISO / CD-ROM первым. Изменение сработает после перезапуска VM.</p>
<script>
(function(){
const list = document.getElementById('boot-order-list');
const input = document.getElementById('boot-order-value');
if (!list || !input) return;
const initialMap = {'cdrom_disk':['cdrom','hd','network'],'disk_cdrom':['hd','cdrom','network'],'network_disk':['network','hd','cdrom'],'disk':['hd','cdrom','network'],'auto':['hd','cdrom','network']};
const order = initialMap[list.dataset.current || 'auto'] || initialMap.auto;
const nodes = Array.from(list.querySelectorAll('.boot-order-item'));
order.forEach(device => { const node = nodes.find(item => item.dataset.device === device); if (node) list.appendChild(node); });
function updateValue(){
const devices = Array.from(list.querySelectorAll('.boot-order-item')).map(item => item.dataset.device);
const first = devices[0]; const second = devices[1];
if (first === 'cdrom' && second === 'hd') input.value = 'cdrom_disk';
else if (first === 'hd' && second === 'cdrom') input.value = 'disk_cdrom';
else if (first === 'network' && second === 'hd') input.value = 'network_disk';
else if (first === 'hd') input.value = 'disk';
else input.value = 'auto';
}
let dragged = null;
list.addEventListener('dragstart', event => { dragged = event.target.closest('.boot-order-item'); if (!dragged) return; dragged.classList.add('dragging'); event.dataTransfer.effectAllowed = 'move'; });
list.addEventListener('dragend', () => { if (dragged) dragged.classList.remove('dragging'); dragged = null; updateValue(); });
list.addEventListener('dragover', event => { event.preventDefault(); const after = Array.from(list.querySelectorAll('.boot-order-item:not(.dragging)')).find(item => { const box = item.getBoundingClientRect(); return event.clientY < box.top + box.height / 2; }); if (!dragged) return; if (after) list.insertBefore(dragged, after); else list.appendChild(dragged); });
updateValue();
})();
</script>
</article>
'''
if template_path.exists():
tpl = template_path.read_text()
original = tpl
settings_grid = '\n\n <section class="grid two vm-boot-iso-grid">\n' + iso_card + boot_card + ' </section>\n'
if 'action="/vm/{{ vm.name }}/iso/mount"' not in tpl and 'vm-boot-iso-grid' not in tpl:
markers = ['\n\n <section class="grid three vm-resource-boot-iso-grid">', '\n\n <section class="grid two">', '\n\n <section class="card">']
for marker in markers:
if marker in tpl:
if 'vm-resource-boot-iso-grid' in marker:
insert_pos = tpl.find(marker) + len(marker)
tpl = tpl[:insert_pos] + '\n' + iso_card + boot_card + tpl[insert_pos:]
changed.append('ISO and boot cards inserted into three-column grid')
else:
tpl = tpl.replace(marker, settings_grid + marker, 1)
changed.append('ISO and boot cards inserted by fallback')
break
else:
changed.append('ISO layout skipped: safe template marker not found')
elif 'vm-boot-iso-grid' in tpl and 'boot-order-list' not in tpl:
changed.append('ISO grid exists but draggable boot marker absent; left unchanged safely')
else:
changed.append('ISO mount template already present')
if tpl != original:
template_path.write_text(tpl)
else:
changed.append('ISO template skipped: vm_detail.html not found')
print('existing VM ISO mount patch applied:')
for item in changed:
print(f'- {item}')
-205
Просмотреть файл
@@ -1,205 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
changed = []
warnings = []
text = app_path.read_text()
if 'import tempfile' not in text:
text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1)
changed.append('tempfile import added')
else:
changed.append('tempfile import already present')
if 'import xml.etree.ElementTree as ET' not in text:
text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1)
changed.append('ElementTree import added')
else:
changed.append('ElementTree import already present')
helpers = r'''
def parse_dominfo_value(dominfo: str, label: str) -> str:
for line in (dominfo or "").splitlines():
if line.strip().lower().startswith(label.lower()):
return line.split(":", 1)[1].strip()
return ""
def kib_text_to_mb(value: str) -> int:
match = re.search(r"(\d+)", value or "")
if not match:
return 0
return max(0, int(int(match.group(1)) / 1024))
def current_vm_arch(name: str) -> str:
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
if not result.get("ok"):
return "unknown"
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception:
return "unknown"
os_type = root.find("os/type")
return (os_type.attrib.get("arch") if os_type is not None else "") or "unknown"
def vm_runtime_state(name: str) -> str:
result = run_cmd(["virsh", "domstate", name], timeout=8)
return (result.get("stdout") or "unknown").strip().lower()
def vm_resource_settings(name: str) -> dict[str, Any]:
dominfo = run_cmd(["virsh", "dominfo", name], timeout=10).get("stdout") or ""
state = (parse_dominfo_value(dominfo, "State") or vm_runtime_state(name)).lower()
vcpus_raw = parse_dominfo_value(dominfo, "CPU(s)")
used_memory_raw = parse_dominfo_value(dominfo, "Used memory")
max_memory_raw = parse_dominfo_value(dominfo, "Max memory")
memory_mb = kib_text_to_mb(used_memory_raw) or kib_text_to_mb(max_memory_raw) or 1024
try:
vcpus = int(re.search(r"\d+", vcpus_raw or "1").group(0))
except Exception:
vcpus = 1
return {
"state": state,
"is_shutoff": state in ("shut off", "shutoff", "shut-off"),
"memory_mb": memory_mb,
"vcpus": vcpus,
"arch": current_vm_arch(name),
}
def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
resources = vm_resource_settings(name)
if not resources.get("is_shutoff"):
return False, "CPU/RAM/архитектуру можно менять только когда VM выключена. Сначала выключи VM."
if memory_mb < 512 or memory_mb > 262144:
return False, "RAM должна быть от 512 MB до 262144 MB."
if vcpus < 1 or vcpus > 128:
return False, "CPU должен быть от 1 до 128 vCPU."
if guest_arch not in ("keep", "x86_64", "aarch64"):
return False, "Некорректная архитектура VM."
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
if not result.get("ok"):
return False, result.get("stderr") or "Не удалось получить XML VM."
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception as exc:
return False, f"Не удалось разобрать XML VM: {exc}"
memory_kib = str(int(memory_mb) * 1024)
for tag in ("memory", "currentMemory"):
node = root.find(tag)
if node is None:
node = ET.SubElement(root, tag)
node.text = memory_kib
node.set("unit", "KiB")
vcpu_node = root.find("vcpu")
if vcpu_node is None:
vcpu_node = ET.SubElement(root, "vcpu")
vcpu_node.text = str(int(vcpus))
vcpu_node.set("placement", "static")
arch_changed = False
if guest_arch != "keep":
os_type = root.find("os/type")
if os_type is None:
os_node = root.find("os")
if os_node is None:
os_node = ET.SubElement(root, "os")
os_type = ET.SubElement(os_node, "type")
os_type.text = "hvm"
old_arch = os_type.attrib.get("arch", "")
if old_arch != guest_arch:
os_type.set("arch", guest_arch)
if guest_arch == "aarch64":
os_type.set("machine", "virt")
arch_changed = True
xml_text = ET.tostring(root, encoding="unicode")
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
handle.write(xml_text)
tmp_name = handle.name
try:
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
finally:
Path(tmp_name).unlink(missing_ok=True)
if not define.get("ok"):
return False, define.get("stderr") or "virsh define завершился ошибкой."
message = f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB"
if arch_changed:
message += f", архитектура {guest_arch}. Важно: смена архитектуры может потребовать совместимый диск/загрузчик."
return True, message
'''
if 'def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM resources helpers added')
else:
warnings.append('vm_details marker not found, helpers skipped')
else:
changed.append('existing VM resources helpers already present')
if '"resource_settings": vm_resource_settings(name)' not in text:
replacements = [
('"iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})', '"iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
('"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})', '"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
('"host_ip": system_summary()["ip"]})', '"host_ip": system_summary()["ip"], "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
]
for old, new in replacements:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets resource settings')
break
else:
warnings.append('vm detail context marker not found, resource context skipped')
else:
changed.append('vm detail context already has resource settings')
route = r'''
@app.post("/vm/{name}/resources")
def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...), guest_arch: str = Form("keep")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = apply_vm_resources(name, memory_mb, vcpus, guest_arch)
if ok:
return RedirectResponse(url=f"/vm/{name}?resource_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?resource_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/resources")' not in text:
markers = ['\n\n@app.post("/vm/{name}/boot-order")', '\n\n@app.post("/vm/{name}/iso/mount")', '\n\n@app.post("/vm/{name}/{action}")']
for marker in markers:
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('existing VM resources route added')
break
else:
warnings.append('vm action marker not found, resources route skipped')
else:
changed.append('existing VM resources route already present')
app_path.write_text(text)
print('existing VM resources patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
-154
Просмотреть файл
@@ -1,154 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.parent
static_dir = app_dir / 'static'
static_dir.mkdir(exist_ok=True)
panel_js_path = static_dir / 'panel.js'
app_css_path = static_dir / 'app.css'
changed = []
text = app_path.read_text()
live_routes = r'''
@app.get("/live/status")
def live_status(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
vms = []
for vm in parse_virsh_list():
name = vm.get("name", "")
state = vm.get("state", "unknown")
try:
ip = vm_ip(name) if name else "—"
except Exception:
ip = "—"
css = "ok" if "running" in state else "err" if "shut" in state else "warn"
vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—"})
return JSONResponse({"ok": True, "generated_at": utc_now(), "vms": vms, "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "web": service_state("virtuality-web.service")}, "operations": list_operations(5)})
@app.get("/live/operations")
def live_operations(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return JSONResponse({"ok": True, "generated_at": utc_now(), "operations": list_operations(25)})
'''
if '@app.get("/live/status"' not in text:
marker = '\n\n@app.get("/api/health")'
text = text.replace(marker, live_routes + marker, 1) if marker in text else text.rstrip() + live_routes + '\n'
changed.append('live routes added')
else:
changed.append('live routes already present')
app_path.write_text(text)
panel_js = panel_js_path.read_text() if panel_js_path.exists() else ''
live_js = r'''
/* 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') {
if (!message) return;
let wrap = qs('#v-toast-wrap');
if (!wrap) { wrap = document.createElement('div'); wrap.id = 'v-toast-wrap'; document.body.appendChild(wrap); }
const item = document.createElement('div');
item.className = 'v-toast ' + type;
item.textContent = message;
wrap.appendChild(item);
setTimeout(() => item.classList.add('show'), 20);
setTimeout(() => { item.classList.remove('show'); setTimeout(() => item.remove(), 260); }, 3600);
}
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 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]) 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';
}
});
}
function updateDetailHeader(vms) {
const title = qs('.brand');
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') && !qs('.shell'))) return;
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);
} 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();
})();
'''
if 'Virtuality live status + toast layer' not in panel_js:
panel_js_path.write_text(panel_js.rstrip() + live_js + '\n')
changed.append('live javascript added')
else:
changed.append('live javascript already present')
app_css = app_css_path.read_text() if app_css_path.exists() else ''
live_css = r'''
/* 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; }
'''
if 'Virtuality live status + toast layer' not in app_css:
app_css_path.write_text(app_css.rstrip() + live_css + '\n')
changed.append('live css added')
else:
changed.append('live css already present')
print('live status patch applied:')
for item in changed:
print(f'- {item}')
-166
Просмотреть файл
@@ -1,166 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
templates_dir = app_path.parent / 'templates'
static_dir = app_path.parent / 'static'
text = app_path.read_text()
changed = []
helpers = r'''
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"},
}
def read_log_source(source: str, lines: int = 220) -> dict[str, Any]:
key = source if source in LOG_SOURCES else "web"
cfg = LOG_SOURCES[key]
lines = max(20, min(int(lines or 220), 2000))
content = ""
path = ""
cmd = ""
if cfg["kind"] == "journal":
unit = cfg["unit"]
cmd = f"journalctl -u {unit} -n {lines} --no-pager"
result = run_cmd(["journalctl", "-u", unit, "-n", str(lines), "--no-pager"], timeout=15)
content = result["stdout"] or result["stderr"] or "Лог пуст или journalctl недоступен"
elif cfg["kind"] == "file":
path = cfg["path"]
cmd = f"tail -n {lines} {path}"
content = tail_text(Path(path), max_lines=lines) or "Файл лога пока пуст или не найден"
elif cfg["kind"] == "glob":
pattern = cfg["pattern"]
files = sorted(Path('/').glob(pattern.lstrip('/')), key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True)
if files:
path = str(files[0])
cmd = f"tail -n {lines} {path}"
content = tail_text(files[0], max_lines=lines) or "Файл лога пуст"
else:
cmd = f"ls {pattern}"
content = "Логи установки ещё не найдены"
elif cfg["kind"] == "operations":
cmd = f"tail -n {lines} /var/log/virtuality/operations/*.log"
parts = []
ensure_operations_dir()
for item in sorted(OPERATIONS_DIR.glob('*.log'), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
parts.append(f"===== {item.name} =====\n" + tail_text(item, max_lines=max(20, lines // 5)))
content = "\n\n".join(parts) or "Журналы операций пока пусты"
return {"key": key, "title": cfg["title"], "content": content, "path": path, "cmd": cmd, "lines": lines}
'''
if 'LOG_SOURCES = {' not in text:
markers = [
'\n\ndef parse_virsh_list() -> list[dict[str, str]]:',
'\n\ndef list_recent_operations(limit: int = 20) -> list[dict[str, Any]]:',
'\n\ndef safe_iso_filename(filename: str) -> str | None:',
]
for marker in markers:
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('log center helpers added')
break
else:
print('WARN: log helper marker not found, skip helper injection')
changed.append('log center helpers skipped')
else:
start = text.find('LOG_SOURCES = {')
end = text.find('\n}\n\n\ndef read_log_source', start)
if start != -1 and end != -1:
replacement = helpers.strip().split('\n\n\ndef read_log_source', 1)[0]
text = text[:start] + replacement + text[end + 3:]
changed.append('log sources refreshed without telegram')
else:
text = text.replace(' "telegram": {"title": "Telegram notifier", "kind": "file", "path": "/var/log/virtuality/telegram_version_bot.log"},\n', '')
changed.append('telegram log source removed')
route = r'''
@app.get("/logs", response_class=HTMLResponse)
def logs_page(request: Request, source: str = "web", lines: int = 220):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
selected = read_log_source(source, lines)
return templates.TemplateResponse("logs.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"sources": LOG_SOURCES,
"selected": selected,
})
@app.get("/api/logs")
def api_logs(request: Request, source: str = "web", lines: int = 220):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return {"ok": True, "log": read_log_source(source, lines)}
'''
if '@app.get("/logs"' not in text:
markers = ['\n\n@app.get("/vm/create", response_class=HTMLResponse)', '\n\n@app.get("/api/operations")', '\n\n@app.get("/iso", response_class=HTMLResponse)']
for marker in markers:
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('logs routes added')
break
else:
print('WARN: logs route insert marker not found, skip route injection')
changed.append('logs routes skipped')
else:
changed.append('logs routes already present')
app_path.write_text(text)
sidebar_html = '''{% set path = request.url.path %}
<aside class="v-sidebar">
<div class="v-logo">
<strong>Virtuality</strong>
<span>control panel</span>
</div>
<nav class="v-nav">
<a class="{{ 'active' if path == '/' else '' }}" href="/">Обзор</a>
<a class="{{ 'active' if path.startswith('/vm/create') else '' }}" href="/vm/create">Создать VM</a>
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') else '' }}">
<button class="v-nav-group-title" type="button">Образы</button>
<a class="{{ 'active' if path.startswith('/iso') else '' }}" href="/iso">ISO</a>
<a class="{{ 'active' if path.startswith('/disk-images') else '' }}" href="/disk-images">Диски</a>
</div>
<a class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
<a class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
<a class="{{ 'active' if path.startswith('/logs') else '' }}" href="/logs">Журналы</a>
<a class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
<a class="{{ 'active' if path.startswith('/host') else '' }}" href="/host">Хост</a>
</nav>
</aside>
'''
if templates_dir.exists():
sidebar_path = templates_dir / '_sidebar.html'
sidebar_path.write_text(sidebar_html)
changed.append('_sidebar.html ensured')
for path in sorted(templates_dir.glob('*.html')):
if path.name in {'login.html', 'console.html', '_sidebar.html'}:
continue
html = path.read_text()
if '<script src="/static/panel.js" defer></script>' not in html:
html = html.replace('</body>', ' <script src="/static/panel.js" defer></script>\n</body>', 1)
changed.append(f'{path.name} panel.js attached')
path.write_text(html)
print('logs center and UI dynamics patch applied:')
for item in changed:
print(f'- {item}')
-148
Просмотреть файл
@@ -1,148 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
core_path = app_path.with_name('network_core.py')
if not core_path.exists():
raise SystemExit(f'network_core.py not found: {core_path}')
text = core_path.read_text()
changed = []
if 'def route_interface_for_ip(' not in text:
marker = '''def external_interface() -> str:
result = run_cmd(['ip', 'route', 'show', 'default'], timeout=5)
if not result['ok']:
return 'eth0'
match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout'])
return match.group(1) if match else 'eth0'
'''
helper = marker + '''
def route_interface_for_ip(ip: str) -> str:
if not valid_ip(ip):
return NAT_BRIDGE
result = run_cmd(['ip', 'route', 'get', ip], timeout=5)
if not result['ok']:
return NAT_BRIDGE
match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout'])
return match.group(1) if match else NAT_BRIDGE
def forward_guest_interface(item: dict[str, Any]) -> str:
return route_interface_for_ip(str(item.get('guest_ip', '')))
'''
if marker not in text:
raise SystemExit('external_interface marker not found')
text = text.replace(marker, helper, 1)
changed.append('route_interface_for_ip helper added')
else:
changed.append('route_interface_for_ip helper already present')
old_render = '''def render_nft_rules() -> str:
ext = external_interface()
lines = [
'table ip virtuality {',
' chain prerouting {',
' type nat hook prerouting priority dstnat; policy accept;',
]
for item in load_port_forwards():
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
lines += [
' }',
' chain postrouting {',
' type nat hook postrouting priority srcnat; policy accept;',
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
' }',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
f' ip saddr {NAT_SUBNET} accept',
f' ip daddr {NAT_SUBNET} accept',
' }',
'}',
]
return '\\n'.join(lines) + '\\n'
'''
new_render = '''def render_nft_rules() -> str:
ext = external_interface()
forwards = load_port_forwards()
lines = [
'table ip virtuality {',
' chain prerouting {',
' type nat hook prerouting priority dstnat; policy accept;',
]
for item in forwards:
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
lines += [
' }',
' chain postrouting {',
' type nat hook postrouting priority srcnat; policy accept;',
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' ip daddr {item["guest_ip"]} oifname "{guest_iface}" masquerade')
lines += [
' }',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
f' ip saddr {NAT_SUBNET} accept',
f' ip daddr {NAT_SUBNET} accept',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' iifname "{ext}" oifname "{guest_iface}" ip daddr {item["guest_ip"]} accept')
lines.append(f' iifname "{guest_iface}" oifname "{ext}" ip saddr {item["guest_ip"]} ct state established,related accept')
lines += [
' }',
'}',
]
return '\\n'.join(lines) + '\\n'
'''
if old_render in text:
text = text.replace(old_render, new_render, 1)
changed.append('nft rules now support bridge/static VM interfaces')
elif 'forward_guest_interface(item)' in text and 'ip daddr {item["guest_ip"]}' in text:
changed.append('nft bridge/static rules already present')
else:
raise SystemExit('render_nft_rules marker not found')
text = text.replace(
"'out', 'on', NAT_BRIDGE,\n 'to', item['guest_ip'],",
"'out', 'on', forward_guest_interface(item),\n 'to', item['guest_ip'],",
)
if "'out', 'on', forward_guest_interface(item)" in text:
changed.append('UFW route rules now use guest route interface')
old_iptables_line = """ results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', NAT_BRIDGE, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
"""
new_iptables_line = """ guest_iface = forward_guest_interface(item)
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, '-j', 'MASQUERADE']))
"""
if old_iptables_line in text:
text = text.replace(old_iptables_line, new_iptables_line, 1)
changed.append('iptables fallback now supports bridge/static VM interfaces')
elif "guest_iface = forward_guest_interface(item)" in text and "'-o', guest_iface" in text:
changed.append('iptables bridge/static fallback already present')
else:
raise SystemExit('iptables fallback marker not found')
text = text.replace(
"'watch_internal': f\"sudo tcpdump -ni {NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'\",",
"'watch_internal': f\"sudo tcpdump -ni {route_interface_for_ip(vm_ip) if vm_ip else NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'\",",
)
core_path.write_text(text)
print('network bridge forward patch applied:')
for item in changed:
print(f'- {item}')
-43
Просмотреть файл
@@ -1,43 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
if 'def network_diagnose(' in text:
print('network diagnostics handler already applied')
raise SystemExit(0)
handler = '''
@app.post("/network/diagnose", response_class=HTMLResponse)
def network_diagnose(request: Request, vm_name: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
diagnostics = None
error = None
try:
diagnostics = network_core.diagnose_public_access(vm_name, external_port, guest_port, protocol)
except NetworkError as exc:
error = str(exc)
return templates.TemplateResponse("network.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
"ctx": network_core.network_context(),
"error": error,
"diagnostics": diagnostics,
}, status_code=400 if error else 200)
'''
marker = '\n\n@app.get("/operations", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('operations marker not found in app.py')
text = text.replace(marker, handler + marker, 1)
app_path.write_text(text)
print(f'network diagnostics handler applied: {app_path}')
-25
Просмотреть файл
@@ -1,25 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
template_path = app_path.with_name('templates') / 'network.html'
if not template_path.exists():
raise SystemExit(f'network.html not found: {template_path}')
text = template_path.read_text()
old = '<input type="hidden" name="guest_ip" value="auto">'
new = '''<label>
<span>IP VM</span>
<input type="text" name="guest_ip" value="auto" placeholder="auto или 192.168.100.55" required>
<small>Если IP не определяется автоматически, впиши внутренний IP VM вручную.</small>
</label>'''
if old in text:
text = text.replace(old, new, 1)
template_path.write_text(text)
print('network manual IP field added')
elif 'name="guest_ip" value="auto" placeholder="auto' in text:
print('network manual IP field already present')
else:
raise SystemExit('guest_ip marker not found in network.html')
-126
Просмотреть файл
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def safe_network_template(request: Request, error: str | None = None, status_code: int = 200, diagnostics: dict[str, Any] | None = None):
try:
ctx = network_core.network_context()
except Exception as exc:
ctx = {
"nat": {
"name": network_core.NETWORK_NAME,
"bridge": network_core.NAT_BRIDGE,
"subnet": network_core.NAT_SUBNET,
"gateway": network_core.NAT_GATEWAY,
"dhcp": f"{network_core.DHCP_START} - {network_core.DHCP_END}",
"exists": False,
"info": str(exc),
"leases": "",
},
"networks": [],
"forwards": [],
"external_interface": "unknown",
"ip_forward": "unknown",
"nft_rules": "",
}
error = error or f"Ошибка чтения сетевого состояния: {exc}"
return templates.TemplateResponse("network.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
"ctx": ctx,
"error": error,
"diagnostics": diagnostics,
}, status_code=status_code)
'''
if 'def safe_network_template(' not in text:
marker = '\n\n@app.get("/network", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('network route marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('safe network template helper added')
else:
changed.append('safe network template helper already present')
old_get = '''@app.get("/network", response_class=HTMLResponse)
def network_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": error})
'''
new_get = '''@app.get("/network", response_class=HTMLResponse)
def network_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return safe_network_template(request, error=error)
'''
if old_get in text:
text = text.replace(old_get, new_get, 1)
changed.append('network page now uses safe template')
elif 'return safe_network_template(request, error=error)' in text:
changed.append('network page already safe')
else:
changed.append('network page replacement skipped')
old_nat = '''@app.post("/network/nat/setup")
def network_nat_setup(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
network_core.create_nat_network()
network_core.apply_port_forwards()
except NetworkError as exc:
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)
return RedirectResponse(url="/network", status_code=303)
'''
new_nat = '''@app.post("/network/nat/setup")
def network_nat_setup(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
network_core.create_nat_network()
network_core.apply_port_forwards()
except NetworkError as exc:
return safe_network_template(request, error=str(exc), status_code=500)
except Exception as exc:
return safe_network_template(request, error=f"Внутренняя ошибка настройки NAT: {exc}", status_code=500)
return RedirectResponse(url="/network", status_code=303)
'''
if old_nat in text:
text = text.replace(old_nat, new_nat, 1)
changed.append('NAT setup catches all exceptions')
elif 'Внутренняя ошибка настройки NAT' in text:
changed.append('NAT setup already catches all exceptions')
else:
raise SystemExit('network nat setup route marker not found')
replacements = {
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=400)': 'return safe_network_template(request, error=str(exc), status_code=400)',
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)': 'return safe_network_template(request, error=str(exc), status_code=500)',
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc), "diagnostics": None}, status_code=400)': 'return safe_network_template(request, error=str(exc), status_code=400)',
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": None, "diagnostics": diagnostics})': 'return safe_network_template(request, diagnostics=diagnostics)',
}
for old, new in replacements.items():
if old in text:
text = text.replace(old, new)
changed.append('network error response hardened')
app_path.write_text(text)
print('network NAT error patch applied:')
for item in changed:
print(f'- {item}')
-143
Просмотреть файл
@@ -1,143 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
changed = []
text = app_path.read_text()
old_signature = 'def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp"), note: str = Form("")):'
new_signature = 'def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: str = Form(...), guest_port: str = Form(...), protocol: str = Form("tcp"), note: str = Form("")):'
if old_signature in text:
text = text.replace(old_signature, new_signature, 1)
app_path.write_text(text)
changed.append('app.py forward/add accepts port ranges as text')
elif new_signature in text:
changed.append('app.py forward/add already accepts port ranges as text')
else:
raise SystemExit('network_forward_add signature marker not found')
core_path = app_path.with_name('network_core.py')
if core_path.exists():
core_text = core_path.read_text()
before = core_text
if 'def iptables_dnat_port_value(' not in core_text:
marker = """def iptables_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
def port_label"""
replacement = """def iptables_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
def iptables_dnat_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
def successful_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in results if item.get('ok')]
def failed_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in results if not item.get('ok')]
def port_label"""
if marker not in core_text:
raise SystemExit('network_core.py port helper marker not found')
core_text = core_text.replace(marker, replacement, 1)
changed.append('network_core.py iptables DNAT range helper added')
else:
changed.append('network_core.py iptables DNAT range helper already present')
core_text = core_text.replace(
'guest_to = f"{guest_ip}:{iptables_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])}"',
'guest_to_port = iptables_dnat_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
)
core_text = core_text.replace(
'guest_to_port = nft_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
'guest_to_port = iptables_dnat_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
)
old_apply = """def apply_port_forwards() -> dict[str, Any]:
ensure_dirs()
enable_ip_forward()
disable_rp_filter()
items = load_port_forwards()
NFT_FILE.write_text(render_nft_rules())
run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
result = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
if not result['ok']:
raise NetworkError(result['stderr'] or result['stdout'] or 'Не удалось применить nftables-правила')
ufw_results = apply_ufw_route_rules(items)
iptables_results = apply_iptables_fallback(items)
return {
'ok': True,
'file': str(NFT_FILE),
'rules': render_nft_rules(),
'ufw': ufw_results,
'iptables': iptables_results,
}
"""
new_apply = """def apply_port_forwards() -> dict[str, Any]:
ensure_dirs()
enable_ip_forward()
disable_rp_filter()
items = load_port_forwards()
NFT_FILE.write_text(render_nft_rules())
nft_delete = run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
ufw_results = apply_ufw_route_rules(items)
iptables_results = apply_iptables_fallback(items)
iptables_ok = bool(successful_results(iptables_results)) or not items
if not nft_apply['ok'] and not iptables_ok:
details = [
nft_apply.get('stderr') or nft_apply.get('stdout') or 'nftables не применился',
*[
item.get('stderr') or item.get('stdout') or item.get('cmd', 'iptables rule failed')
for item in failed_results(iptables_results)
],
]
raise NetworkError('Не удалось применить правила проброса: ' + ' | '.join([d for d in details if d]))
return {
'ok': nft_apply['ok'] or iptables_ok,
'file': str(NFT_FILE),
'rules': render_nft_rules(),
'nft_delete': nft_delete,
'nft_apply': nft_apply,
'ufw': ufw_results,
'iptables': iptables_results,
}
"""
if old_apply in core_text:
core_text = core_text.replace(old_apply, new_apply, 1)
changed.append('network_core.py apply_port_forwards now falls back to iptables when nft fails')
elif 'nft_apply = run_cmd([\'nft\', \'-f\', str(NFT_FILE)]' in core_text or "nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)]" in core_text:
changed.append('network_core.py apply_port_forwards fallback already present')
else:
raise SystemExit('network_core.py apply_port_forwards marker not found')
core_text = core_text.replace(
'f"{vm_ip}:{matching_forward[\'guest_port_label\'].replace(\'-\', \':\')}" in ipt_nat_text',
'(f"{vm_ip}:{matching_forward[\'guest_port_label\']}" in ipt_nat_text or f"{vm_ip}:{matching_forward[\'guest_port_label\'].replace(\'-\', \':\')}" in ipt_nat_text)',
)
if core_text != before:
core_path.write_text(core_text)
changed.append('network_core.py port forwarding rules patched')
else:
changed.append('network_core.py port forwarding rules already ok')
else:
changed.append(f'network_core.py not found near {app_path}')
print('network ranges patch applied:')
for item in changed:
print(f'- {item}')
-53
Просмотреть файл
@@ -1,53 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web')
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
if not template_path.exists():
print(f'legacy boot order cleanup skipped: vm_detail.html not found: {template_path}')
raise SystemExit(0)
tpl = template_path.read_text()
original = tpl
legacy_pattern = re.compile(
r'\n\s*<section class="card">\s*\n'
r'\s*<div class="card-head">\s*\n'
r'\s*<h2>Порядок загрузки</h2>\s*\n'
r'\s*<span class="pill">boot order</span>\s*\n'
r'.*?'
r'\s*</section>\s*\n',
re.DOTALL,
)
tpl, count = legacy_pattern.subn('\n', tpl)
if count:
changed.append(f'legacy select boot-order card removed: {count}')
# Safety cleanup for any accidental duplicated plain select card without the old pill text.
plain_select_pattern = re.compile(
r'\n\s*<section class="card">\s*\n'
r'\s*<div class="card-head">\s*\n'
r'\s*<h2>Порядок загрузки</h2>.*?'
r'<select name="boot_order" required>.*?'
r'\s*</section>\s*\n',
re.DOTALL,
)
tpl, count2 = plain_select_pattern.subn('\n', tpl)
if count2:
changed.append(f'legacy plain select boot-order card removed: {count2}')
if tpl != original:
template_path.write_text(tpl)
else:
changed.append('legacy boot-order select card not found')
print('legacy boot order cleanup patch applied:')
for item in changed:
print(f'- {item}')
-60
Просмотреть файл
@@ -1,60 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
templates_dir = app_path.parent / 'templates'
if not templates_dir.exists():
raise SystemExit(f'templates dir not found: {templates_dir}')
skip = {'login.html', 'console.html', '_sidebar.html'}
changed = []
skipped = []
for path in sorted(templates_dir.glob('*.html')):
if path.name in skip:
skipped.append(f'{path.name}: skipped system/template')
continue
text = path.read_text()
if '{% include "_sidebar.html" %}' in text or "{% include '_sidebar.html' %}" in text:
skipped.append(f'{path.name}: already has sidebar')
continue
if '<div class="v-layout">' in text:
skipped.append(f'{path.name}: already v-layout')
continue
if '<div class="shell">' not in text:
skipped.append(f'{path.name}: no shell')
continue
text = text.replace(
'<body>\n <div class="shell">',
'<body>\n <div class="v-layout">\n {% include "_sidebar.html" %}\n <main class="v-main">\n <div class="shell v-shell-embedded">',
1,
)
# Close shell/main/layout before scripts when page has JS block.
marker = '\n\n <script>'
if marker in text:
idx = text.rfind('\n </div>', 0, text.find(marker))
if idx != -1:
text = text[:idx] + '\n </div>\n </main>\n </div>' + text[idx + len('\n </div>'):]
else:
skipped.append(f'{path.name}: close marker before script not found')
continue
else:
marker2 = '\n</body>'
idx = text.rfind('\n </div>', 0, text.find(marker2) if marker2 in text else len(text))
if idx != -1:
text = text[:idx] + '\n </div>\n </main>\n </div>' + text[idx + len('\n </div>'):]
else:
skipped.append(f'{path.name}: close marker before body not found')
continue
path.write_text(text)
changed.append(path.name)
print('sidebar layout patch applied:')
for name in changed:
print(f'- {name}: sidebar wrapped')
for item in skipped:
print(f'- {item}')
-56
Просмотреть файл
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import update_core' not in text:
text = text.replace('import network_core\n', 'import network_core\nimport update_core\n', 1)
changed.append('import update_core added')
helper = r'''
def dashboard_update_notice() -> dict[str, Any]:
try:
info = update_core.check_updates(fetch=False)
if info.get("has_update"):
return {
"has_update": True,
"current_version": info.get("current_version", "unknown"),
"latest_version": info.get("latest_version", "unknown"),
"missing_count": len(info.get("missing_versions", [])),
"commit_count": len(info.get("commits", [])),
}
except Exception:
pass
return {"has_update": False}
'''
if 'def dashboard_update_notice() -> dict[str, Any]:' not in text:
marker = '\n\ndef update_operation(operation: dict[str, Any], **changes: Any) -> None:'
if marker not in text:
raise SystemExit('update_operation marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('dashboard update notice helper added')
else:
changed.append('dashboard update notice helper already present')
old_fragment = '"operations": list_operations(5), "operation_css": operation_css})'
new_fragment = '"operations": list_operations(5), "operation_css": operation_css, "update_notice": dashboard_update_notice()})'
if old_fragment in text:
text = text.replace(old_fragment, new_fragment, 1)
changed.append('dashboard context gets update_notice')
elif '"update_notice": dashboard_update_notice()' in text:
changed.append('dashboard context already has update_notice')
else:
raise SystemExit('dashboard context marker not found')
app_path.write_text(text)
print('update badge patch applied:')
for item in changed:
print(f'- {item}')
-127
Просмотреть файл
@@ -1,127 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import update_core' not in text:
if 'import network_core\n' in text:
text = text.replace('import network_core\n', 'import network_core\nimport update_core\n', 1)
elif 'from network_core import NetworkError\n' in text:
text = text.replace('from network_core import NetworkError\n', 'from network_core import NetworkError\nimport update_core\n', 1)
else:
raise SystemExit('network_core import marker not found')
changed.append('import update_core added')
else:
changed.append('import update_core already present')
routes = r'''
@app.get("/update", response_class=HTMLResponse)
def update_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
error = None
try:
info = update_core.check_updates(fetch=False)
except Exception as exc:
error = str(exc)
info = {
"ok": False,
"source_dir": str(update_core.SOURCE_DIR),
"remote": update_core.REMOTE,
"branch": update_core.DEFAULT_BRANCH,
"fetch_ok": False,
"fetch_error": str(exc),
"current_commit": "",
"latest_commit": "",
"current_version": "unknown",
"latest_version": "unknown",
"has_update": False,
"missing_versions": [],
"commits": [],
"checked_at": utc_now(),
"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})
@app.get("/update/status")
def update_status(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
return JSONResponse({
"ok": True,
"state": update_core.state(),
"log_tail": update_core.update_log_tail(260),
"checked_at": utc_now(),
})
@app.post("/update/check")
def update_check(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
update_core.check_updates(fetch=True)
except Exception:
pass
return RedirectResponse(url="/update", status_code=303)
@app.post("/update/apply")
def update_apply(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
update_core.start_update()
except Exception:
pass
return RedirectResponse(url="/update", status_code=303)
'''
if '@app.get("/update"' not in text:
marker = '\n\n@app.get("/operations", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('operations route marker not found')
text = text.replace(marker, routes + marker, 1)
changed.append('update routes added')
else:
changed.append('update route already present')
if '@app.get("/update/status"' not in text:
marker = '\n\n@app.post("/update/check")'
if marker not in text:
raise SystemExit('update/check route marker not found')
status_route = r'''
@app.get("/update/status")
def update_status(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
return JSONResponse({
"ok": True,
"state": update_core.state(),
"log_tail": update_core.update_log_tail(260),
"checked_at": utc_now(),
})
'''
text = text.replace(marker, status_route + marker, 1)
changed.append('update status route added')
else:
changed.append('update status route already present')
app_path.write_text(text)
print('update center patch applied:')
for item in changed:
print(f'- {item}')
-96
Просмотреть файл
@@ -1,96 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
safe_name_helper = r'''
def safe_upload_filename(filename: str, allowed_suffixes: tuple[str, ...], fallback_prefix: str) -> str | None:
original = Path(filename or "").name.strip()
suffix = Path(original).suffix.lower()
if suffix not in allowed_suffixes:
return None
stem = Path(original).stem.strip()
stem = re.sub(r"\s+", "-", stem)
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
stem = stem.strip("._-")
if not stem:
stem = fallback_prefix
stem = stem[:120]
name = f"{stem}{suffix}"
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,180}\.[a-zA-Z0-9]{2,8}", name):
name = f"{fallback_prefix}{suffix}"
return name
'''
if 'def safe_upload_filename(' not in text:
marker = '\n\ndef safe_iso_filename(filename: str) -> str | None:'
if marker not in text:
raise SystemExit('safe_iso_filename marker not found')
text = text.replace(marker, safe_name_helper + marker, 1)
changed.append('safe upload filename helper added')
else:
changed.append('safe upload filename helper already present')
old_iso = r'''def safe_iso_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith(".iso"):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.iso", name):
return None
return name
'''
new_iso = r'''def safe_iso_filename(filename: str) -> str | None:
return safe_upload_filename(filename, (".iso",), "virtuality-iso")
'''
if old_iso in text:
text = text.replace(old_iso, new_iso, 1)
changed.append('ISO filename sanitizer relaxed')
elif 'return safe_upload_filename(filename, (".iso",), "virtuality-iso")' in text:
changed.append('ISO filename sanitizer already relaxed')
else:
changed.append('ISO filename sanitizer replacement skipped')
old_disk = r'''def safe_disk_image_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith((".img", ".raw", ".qcow2")):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE):
return None
return name
'''
new_disk = r'''def safe_disk_image_filename(filename: str) -> str | None:
return safe_upload_filename(filename, (".img", ".raw", ".qcow2"), "virtuality-disk")
'''
if old_disk in text:
text = text.replace(old_disk, new_disk, 1)
changed.append('disk image filename sanitizer relaxed')
elif 'return safe_upload_filename(filename, (".img", ".raw", ".qcow2"), "virtuality-disk")' in text:
changed.append('disk image filename sanitizer already relaxed')
else:
changed.append('disk image filename sanitizer not found yet')
# Make upload errors more explicit when temp/filesystem fails after multipart parsing.
text = text.replace(
'"error": f"Ошибка загрузки ISO: {exc}"}, status_code=500)',
'"error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)'
)
text = text.replace(
'"error": f"Ошибка загрузки образа: {exc}"}, status_code=500)',
'"error": f"Ошибка загрузки образа: {exc}. Проверь свободное место, права на /var/lib/virtuality/disk-images и временный каталог /var/lib/virtuality/tmp."}, status_code=500)'
)
changed.append('upload error messages clarified')
app_path.write_text(text)
print('upload compatibility patch applied:')
for item in changed:
print(f'- {item}')
-73
Просмотреть файл
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_dir = Path(sys.argv[1]).resolve().parent if len(sys.argv) > 1 else Path('/opt/virtuality/web')
templates_dir = app_dir / 'templates'
changed = []
guard_script = r'''
function setUploadNavigationGuard(active) {
window.virtualityUploadActive = Boolean(active);
document.body.classList.toggle('upload-locked', window.virtualityUploadActive);
}
window.addEventListener('beforeunload', function (event) {
if (!window.virtualityUploadActive) return;
event.preventDefault();
event.returnValue = 'Идёт загрузка файла. Переход остановит передачу.';
return event.returnValue;
});
document.addEventListener('click', function (event) {
if (!window.virtualityUploadActive) return;
const target = event.target.closest('a, button, input[type="submit"]');
if (!target) return;
if (target.id === 'iso-upload-button' || target.id === 'disk-upload-button') return;
event.preventDefault();
event.stopPropagation();
alert('Идёт загрузка файла. Дождись завершения или нажми «Отменить загрузку». Переход по меню сейчас заблокирован, чтобы файл не оборвался.');
}, true);
document.addEventListener('submit', function (event) {
if (!window.virtualityUploadActive) return;
if (event.target && (event.target.id === 'iso-upload-form' || event.target.id === 'disk-upload-form')) return;
event.preventDefault();
event.stopPropagation();
alert('Идёт загрузка файла. Дождись завершения или нажми «Отменить загрузку».');
}, true);
'''
def patch_template(path: Path, active_token: str, form_id: str) -> None:
if not path.exists():
return
text = path.read_text()
original = text
if 'function setUploadNavigationGuard(active)' not in text:
marker = ' function bytesText(bytes) {'
if marker not in text:
raise SystemExit(f'bytesText marker not found in {path}')
text = text.replace(marker, guard_script + '\n' + marker, 1)
text = text.replace(' uploadActive = active;\n', ' uploadActive = active;\n setUploadNavigationGuard(active);\n')
# During server-side post-processing after upload, file transfer is done, so page navigation is safe again.
text = text.replace(" window.location.href = '/iso';", " setUploadNavigationGuard(false);\n window.location.href = '/iso';")
text = text.replace(" window.location.href = '/disk-images';", " setUploadNavigationGuard(false);\n window.location.href = '/disk-images';")
# Conversion polling is not an active browser upload anymore; allow navigation while backend operation continues.
text = text.replace(" setUploadMode(false);\n fileInput.disabled = true;", " setUploadMode(false);\n setUploadNavigationGuard(false);\n fileInput.disabled = true;")
if text != original:
path.write_text(text)
changed.append(str(path))
patch_template(templates_dir / 'iso.html', 'iso-upload-button', 'iso-upload-form')
patch_template(templates_dir / 'disk_images.html', 'disk-upload-button', 'disk-upload-form')
print('upload navigation guard patch applied:')
for item in changed or ['already applied']:
print(f'- {item}')
-147
Просмотреть файл
@@ -1,147 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helpers = r'''
def vm_arch_options(profile: dict[str, Any]) -> list[dict[str, str]]:
host_arch = str(profile.get("arch", ""))
options = [
{"value": "auto", "label": "Авто — рекомендовано для этого хоста"},
{"value": "x86_64", "label": "x86_64 / amd64 — обычные ПК и серверы"},
]
if host_arch in ("aarch64", "arm64"):
options.append({"value": "aarch64", "label": "ARM64 / aarch64 — KVM на ARM-хосте"})
else:
options.append({"value": "aarch64", "label": "ARM64 / aarch64 — QEMU-эмуляция, медленно"})
options.append({"value": "generic", "label": "Generic QEMU — экспериментально"})
return options
def resolve_vm_arch(arch_choice: str, profile: dict[str, Any]) -> str:
if arch_choice == "auto" or not arch_choice:
return str(profile.get("recommended_guest_arch") or "x86_64")
if arch_choice in ("x86_64", "aarch64", "generic"):
return arch_choice
return str(profile.get("recommended_guest_arch") or "x86_64")
def append_arch_args(cmd: list[str], guest_arch: str, profile: dict[str, Any]) -> list[str]:
host_arch = str(profile.get("arch", ""))
if guest_arch == "aarch64":
if host_arch in ("aarch64", "arm64"):
return cmd + ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
return cmd + ["--arch", "aarch64", "--machine", "virt", "--cpu", "cortex-a57", "--virt-type", "qemu", "--boot", "uefi"]
if guest_arch == "x86_64":
if host_arch in ("x86_64", "amd64"):
return cmd + ["--arch", "x86_64"]
return cmd + ["--arch", "x86_64", "--virt-type", "qemu"]
return cmd
'''
start = text.find('def vm_arch_options(profile: dict[str, Any])')
if start != -1:
end = text.find('\n\ndef vm_form_context(', start)
if end == -1:
raise SystemExit('vm_form_context end marker not found')
text = text[:start] + helpers.strip() + text[end:]
changed.append('architecture helpers replaced')
else:
marker = '\n\ndef vm_form_context('
if marker not in text:
raise SystemExit('vm_form_context marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('architecture helpers added')
old_context = '"disk_images": list_disk_image_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context = '"disk_images": list_disk_image_files(), "arch_options": vm_arch_options(profile), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context in text:
text = text.replace(old_context, new_context, 1)
changed.append('vm form context gets arch options')
elif '"arch_options": vm_arch_options(profile)' in text:
changed.append('vm form context already has arch options')
else:
old_context2 = '"isos": list_iso_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context2 = '"isos": list_iso_files(), "disk_images": list_disk_image_files() if "list_disk_image_files" in globals() else [], "arch_options": vm_arch_options(profile), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context2 in text:
text = text.replace(old_context2, new_context2, 1)
changed.append('vm form context gets arch options fallback')
else:
raise SystemExit('vm_form_context payload marker not found')
old_sig = '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"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = '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"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('vm create signature gets guest_arch')
elif new_sig in text:
changed.append('vm create signature already has guest_arch')
else:
old_sig2 = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig2 = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig2 in text:
text = text.replace(old_sig2, new_sig2, 1)
changed.append('vm create signature gets guest_arch fallback')
else:
raise SystemExit('vm_create_submit signature marker not found')
old_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "network_mode": network_mode, "bridge": bridge}'
new_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_form in text:
text = text.replace(old_form, new_form, 1)
changed.append('form state stores guest_arch')
elif '"guest_arch": guest_arch' in text:
changed.append('form state already stores guest_arch')
else:
old_form2 = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}'
new_form2 = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_form2 in text:
text = text.replace(old_form2, new_form2, 1)
changed.append('form state stores guest_arch fallback')
old_validation = 'elif source_type not in ("iso", "disk_image"):\n error = "Некорректный источник VM."'
new_validation = 'elif source_type not in ("iso", "disk_image"):\n error = "Некорректный источник VM."\n elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."'
if old_validation in text and 'Некорректная архитектура VM' not in text:
text = text.replace(old_validation, new_validation, 1)
changed.append('guest_arch validation added')
elif 'Некорректная архитектура VM' in text:
changed.append('guest_arch validation already present')
old_arch_block = ''' profile = host_profile.load_host_profile()
is_arm = profile.get("recommended_guest_arch") == "aarch64"
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
if is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
'''
new_arch_block = ''' profile = host_profile.load_host_profile()
resolved_guest_arch = resolve_vm_arch(guest_arch, profile)
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
cmd = append_arch_args(cmd, resolved_guest_arch, profile)
'''
if old_arch_block in text:
text = text.replace(old_arch_block, new_arch_block, 1)
changed.append('virt-install arch args made selectable')
elif 'cmd = append_arch_args(cmd, resolved_guest_arch)' in text:
text = text.replace('cmd = append_arch_args(cmd, resolved_guest_arch)', 'cmd = append_arch_args(cmd, resolved_guest_arch, profile)')
changed.append('virt-install arch args now use host profile')
elif 'cmd = append_arch_args(cmd, resolved_guest_arch, profile)' in text:
changed.append('virt-install arch args already use host profile')
else:
raise SystemExit('arch command block marker not found')
text = text.replace('"guest_arch": profile.get("recommended_guest_arch"),', '"guest_arch": resolved_guest_arch, "guest_arch_choice": guest_arch,')
text = text.replace('"guest_arch": profile.get("recommended_guest_arch")', '"guest_arch": resolved_guest_arch, "guest_arch_choice": guest_arch')
app_path.write_text(text)
print('vm architecture patch applied:')
for item in changed:
print(f'- {item}')
-67
Просмотреть файл
@@ -1,67 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def vm_autostart_status(name: str) -> dict[str, str | bool]:
result = run_cmd(["virsh", "dominfo", name], timeout=8)
output = result.get("stdout", "") or ""
match = re.search(r"^Autostart:\s*(.+)$", output, re.MULTILINE | re.IGNORECASE)
raw = match.group(1).strip() if match else "unknown"
enabled = raw.lower() in ("enable", "enabled", "yes", "on")
label = "enabled" if enabled else "disabled" if raw != "unknown" else "unknown"
css = "ok" if enabled else "warn"
return {"enabled": enabled, "label": label, "css": css, "raw": raw}
'''
if 'def vm_autostart_status(' not in text:
marker = '\n\ndef parse_virsh_list() -> list[dict[str, str]]:'
if marker not in text:
raise SystemExit('parse_virsh_list marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('vm_autostart_status helper added')
else:
changed.append('vm_autostart_status helper already present')
old_rows = ''' if len(parts) == 3:\n rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})\n elif len(parts) == 2:\n rows.append({"id": "-", "name": parts[0], "state": parts[1]})'''
new_rows = ''' if len(parts) == 3:\n autostart = vm_autostart_status(parts[1])\n rows.append({"id": parts[0], "name": parts[1], "state": parts[2], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})\n elif len(parts) == 2:\n autostart = vm_autostart_status(parts[0])\n rows.append({"id": "-", "name": parts[0], "state": parts[1], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})'''
if old_rows in text:
text = text.replace(old_rows, new_rows, 1)
changed.append('parse_virsh_list enriched with autostart')
elif 'autostart_enabled' in text and 'autostart_label' in text:
changed.append('parse_virsh_list already has autostart')
else:
raise SystemExit('parse_virsh_list row marker not found')
old_details = '''def vm_details(name: str) -> dict[str, Any]:\n return {\n "name": name,\n "dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],\n "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",\n "ip": vm_ip(name),\n "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],\n "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],\n "autostart": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],\n }'''
new_details = '''def vm_details(name: str) -> dict[str, Any]:\n dominfo = run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"]\n autostart = vm_autostart_status(name)\n return {\n "name": name,\n "dominfo": dominfo,\n "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",\n "ip": vm_ip(name),\n "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],\n "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],\n "autostart": dominfo,\n "autostart_enabled": autostart["enabled"],\n "autostart_label": autostart["label"],\n "autostart_css": autostart["css"],\n }'''
if old_details in text:
text = text.replace(old_details, new_details, 1)
changed.append('vm_details enriched with autostart')
elif '"autostart_enabled": autostart["enabled"]' in text:
changed.append('vm_details already has autostart')
else:
raise SystemExit('vm_details marker not found')
old_live = ''' vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—"})'''
new_live = ''' vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—", "autostart_enabled": vm.get("autostart_enabled", False), "autostart_label": vm.get("autostart_label", "unknown"), "autostart_css": vm.get("autostart_css", "warn")})'''
if old_live in text:
text = text.replace(old_live, new_live, 1)
changed.append('live status enriched with autostart')
elif '"autostart_label": vm.get("autostart_label", "unknown")' in text:
changed.append('live status already has autostart')
else:
changed.append('live status marker not found, skipped')
app_path.write_text(text)
print('vm autostart patch applied:')
for item in changed:
print(f'- {item}')
-214
Просмотреть файл
@@ -1,214 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_create.html'
changed = []
warnings = []
text = app_path.read_text()
helpers = r'''
def vm_boot_order_options() -> list[dict[str, str]]:
return [
{"value": "auto", "label": "Auto — по источнику VM"},
{"value": "disk", "label": "Сначала диск"},
{"value": "cdrom_disk", "label": "Сначала ISO/CD-ROM, потом диск"},
{"value": "disk_cdrom", "label": "Сначала диск, потом ISO/CD-ROM"},
{"value": "network_disk", "label": "Сначала сеть/PXE, потом диск"},
]
def normalize_boot_order(value: str, source_type: str) -> str:
value = (value or "auto").strip()
if value == "auto":
return "cdrom_disk" if source_type == "iso" else "disk"
if value in ("disk", "cdrom_disk", "disk_cdrom", "network_disk"):
return value
return "cdrom_disk" if source_type == "iso" else "disk"
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
mapping = {
"disk": "hd",
"cdrom_disk": "cdrom,hd",
"disk_cdrom": "hd,cdrom",
"network_disk": "network,hd",
}
value = mapping.get(boot_order, "hd")
if is_arm:
return "uefi," + value
return value
'''
def replace_once(source: str, old: str, new: str, label: str) -> str:
if old in source:
changed.append(label)
return source.replace(old, new, 1)
warnings.append(f'{label}: marker not found, skipped')
return source
if 'def vm_boot_order_options()' not in text:
marker = '\n\ndef vm_form_context('
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('boot order helpers added')
else:
warnings.append('vm_form_context marker not found, helpers skipped')
else:
changed.append('boot order helpers already present')
if '"boot_options": vm_boot_order_options()' not in text:
if '"arch_options": vm_arch_options(),' in text:
text = text.replace('"arch_options": vm_arch_options(),', '"arch_options": vm_arch_options(), "boot_options": vm_boot_order_options(),', 1)
changed.append('vm form context gets boot options')
else:
warnings.append('arch_options marker not found, boot_options skipped')
else:
changed.append('vm form context already has boot options')
if '"boot_order": "auto"' not in text:
if '"guest_arch": "auto", "network_mode"' in text:
text = text.replace('"guest_arch": "auto", "network_mode"', '"guest_arch": "auto", "boot_order": "auto", "network_mode"', 1)
changed.append('default form boot_order added')
elif '"guest_arch": "auto"' in text:
text = text.replace('"guest_arch": "auto"', '"guest_arch": "auto", "boot_order": "auto"', 1)
changed.append('default form boot_order added near guest_arch')
else:
warnings.append('default form marker not found, boot_order default skipped')
else:
changed.append('default form boot_order already present')
if 'boot_order: str = Form("auto")' not in text:
signature_patterns = [
('guest_arch: str = Form("auto"), network_mode:', 'guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode:'),
('guest_arch: str = Form("auto"),\n network_mode:', 'guest_arch: str = Form("auto"),\n boot_order: str = Form("auto"),\n network_mode:'),
]
for old, new in signature_patterns:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm create signature supports boot order')
break
else:
warnings.append('vm create signature marker not found, skipped')
else:
changed.append('vm create signature already supports boot order')
if '"boot_order": boot_order' not in text:
if '"guest_arch": guest_arch, "network_mode"' in text:
text = text.replace('"guest_arch": guest_arch, "network_mode"', '"guest_arch": guest_arch, "boot_order": boot_order, "network_mode"', 1)
changed.append('vm create form state supports boot order')
elif '"guest_arch": guest_arch' in text:
text = text.replace('"guest_arch": guest_arch', '"guest_arch": guest_arch, "boot_order": boot_order', 1)
changed.append('vm create form state supports boot order near guest_arch')
else:
warnings.append('vm create form state marker not found, skipped')
else:
changed.append('vm create form state already supports boot order')
if 'boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk")' not in text:
validation_old = 'elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."'
validation_new = validation_old + '\n elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):\n error = "Некорректный порядок загрузки VM."'
if validation_old in text:
text = text.replace(validation_old, validation_new, 1)
changed.append('vm create validation supports boot order')
else:
warnings.append('vm create validation marker not found, skipped')
else:
changed.append('vm create validation already supports boot order')
if 'selected_boot_order = normalize_boot_order(boot_order, source_type)' not in text:
if 'selected_arch = normalize_guest_arch(guest_arch, profile)\n' in text:
text = text.replace('selected_arch = normalize_guest_arch(guest_arch, profile)\n', 'selected_arch = normalize_guest_arch(guest_arch, profile)\n selected_boot_order = normalize_boot_order(boot_order, source_type)\n', 1)
changed.append('selected boot order added')
else:
warnings.append('selected_arch marker not found, selected boot order skipped')
else:
changed.append('selected boot order already present')
# Replace legacy ARM-specific --boot uefi in architecture block with unified --boot argument if present.
if ', "--boot", "uefi"]' in text:
text = text.replace(', "--boot", "uefi"]', ']')
changed.append('legacy ARM --boot uefi removed')
if 'cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]' not in text:
marker = ' if source_type == "disk_image":\n'
if marker in text:
text = text.replace(marker, ' cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]\n\n' + marker, 1)
changed.append('virt-install boot argument added')
else:
warnings.append('source_type command marker not found, boot argument skipped')
else:
changed.append('virt-install boot argument already present')
if '"boot_order": selected_boot_order' not in text:
if '"guest_arch": selected_arch, "host_profile"' in text:
text = text.replace('"guest_arch": selected_arch, "host_profile"', '"guest_arch": selected_arch, "boot_order": selected_boot_order, "host_profile"', 1)
changed.append('operation metadata gets boot order')
elif '"guest_arch": selected_arch' in text:
text = text.replace('"guest_arch": selected_arch', '"guest_arch": selected_arch, "boot_order": selected_boot_order', 1)
changed.append('operation metadata gets boot order near guest_arch')
else:
warnings.append('operation metadata marker not found, skipped')
else:
changed.append('operation metadata already has boot order')
app_path.write_text(text)
if template_path.exists():
tpl = template_path.read_text()
original = tpl
if 'name="boot_order"' not in tpl:
insert = r'''
<label>
<span>Порядок загрузки</span>
<select name="boot_order" required>
{% for boot in boot_options %}
<option value="{{ boot.value }}" {% if form.boot_order == boot.value %}selected{% endif %}>{{ boot.label }}</option>
{% endfor %}
</select>
</label>
'''
markers = [
' <label>\n <span>Режим сети</span>',
' <label>\n <span>Режим сети</span>',
]
for marker in markers:
if marker in tpl:
tpl = tpl.replace(marker, insert + '\n' + marker, 1)
changed.append('boot order field added to vm_create.html')
break
else:
warnings.append('network mode marker not found in vm_create.html, boot field skipped')
else:
changed.append('boot order field already present in vm_create.html')
tpl = tpl.replace(
'Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется.',
'Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется. Для готовых дисков обычно выбирай порядок загрузки “Сначала диск”.',
)
tpl = tpl.replace(
'Virtuality создаст новый диск указанного размера и запустит установку с ISO.',
'Virtuality создаст новый диск указанного размера и запустит установку с ISO. Для установщика обычно выбирай “Сначала ISO/CD-ROM, потом диск”.',
)
if tpl != original:
template_path.write_text(tpl)
else:
warnings.append(f'vm_create.html not found: {template_path}')
print('VM boot order patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
-161
Просмотреть файл
@@ -1,161 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web')
template_path = app_dir / 'templates' / 'vm_detail.html'
css_path = app_dir / 'static' / 'app.css'
changed = []
if not template_path.exists():
print(f'VM detail resource layout patch skipped: vm_detail.html not found: {template_path}')
raise SystemExit(0)
tpl = template_path.read_text()
original = tpl
resource_messages = r'''
{% if resource_message %}
<div class="alert success">{{ resource_message }}</div>
{% endif %}
{% if resource_error %}
<div class="alert danger">{{ resource_error }}</div>
{% endif %}
'''
resource_card = r'''
<article class="card">
<div class="card-head">
<h2>CPU / RAM / Архитектура</h2>
<span class="pill">resources</span>
</div>
<div class="resource-mini-grid">
<div><span>CPU</span><b>{{ resource_settings.vcpus }}</b></div>
<div><span>RAM</span><b>{{ resource_settings.memory_mb }} MB</b></div>
<div><span>Архитектура</span><b>{{ resource_settings.arch }}</b></div>
</div>
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid">
<label>
<span>CPU, vCPU</span>
<input type="number" name="vcpus" min="1" max="128" value="{{ resource_settings.vcpus }}" required {% if not resource_settings.is_shutoff %}disabled{% endif %}>
</label>
<label>
<span>RAM, MB</span>
<input type="number" name="memory_mb" min="512" max="262144" step="128" value="{{ resource_settings.memory_mb }}" required {% if not resource_settings.is_shutoff %}disabled{% endif %}>
</label>
<label>
<span>Архитектура</span>
<select name="guest_arch" {% if not resource_settings.is_shutoff %}disabled{% endif %}>
<option value="keep" selected>Не менять — {{ resource_settings.arch }}</option>
<option value="x86_64">x86_64 / amd64</option>
<option value="aarch64">ARM64 / aarch64</option>
</select>
</label>
<button class="primary wide" type="submit" {% if not resource_settings.is_shutoff %}disabled{% endif %}>Применить ресурсы</button>
</form>
{% if not resource_settings.is_shutoff %}
<div class="alert danger">VM сейчас не выключена. CPU, RAM и архитектуру можно менять только после Shutdown/Power off.</div>
{% endif %}
<p class="muted small-note">CPU/RAM меняются в XML VM. Смена архитектуры требует совместимый диск и загрузчик; x86-диск не станет ARM-диском по щелчку, увы, физика всё ещё душнит.</p>
</article>
'''
if 'resource_message' not in tpl:
marker = ' {% if iso_message %}'
if marker in tpl:
tpl = tpl.replace(marker, resource_messages + '\n' + marker, 1)
changed.append('resource messages added before ISO messages')
else:
# Safe fallback: place messages after header/topbar area if exact ISO marker is not present.
marker = ' <section'
index = tpl.find(marker)
if index != -1:
tpl = tpl[:index] + resource_messages + '\n' + tpl[index:]
changed.append('resource messages added by fallback')
else:
changed.append('resource messages skipped: no safe marker')
if 'CPU / RAM / Архитектура' not in tpl:
candidate_markers = [
' <section class="grid three vm-resource-boot-iso-grid">',
' <section class="grid two vm-boot-iso-grid">',
' <section class="grid two">',
' <section class="card">',
]
inserted = False
for marker in candidate_markers:
pos = tpl.find(marker)
if pos == -1:
continue
if 'vm-boot-iso-grid' in marker:
tpl = tpl.replace(marker, ' <section class="grid three vm-resource-boot-iso-grid">', 1)
grid_pos = tpl.find('vm-resource-boot-iso-grid')
insert_at = tpl.find('\n <article class="card">', grid_pos)
if insert_at != -1:
tpl = tpl[:insert_at] + '\n' + resource_card + tpl[insert_at:]
else:
close_at = tpl.find('</section>', grid_pos)
if close_at != -1:
tpl = tpl[:close_at] + resource_card + tpl[close_at:]
changed.append('resource card inserted into existing settings grid')
inserted = True
break
elif marker == ' <section class="grid two">':
settings = '\n\n <section class="grid three vm-resource-boot-iso-grid">\n' + resource_card + ' </section>\n'
tpl = tpl.replace('\n\n' + marker, settings + '\n\n' + marker, 1)
changed.append('resource grid inserted before first two-column grid')
inserted = True
break
else:
settings = '\n\n <section class="grid three vm-resource-boot-iso-grid">\n' + resource_card + ' </section>\n'
tpl = tpl[:pos] + settings + tpl[pos:]
changed.append('resource grid inserted before first card fallback')
inserted = True
break
if not inserted:
changed.append('resource card skipped: no safe layout marker')
else:
changed.append('resource card already present')
# Upgrade older two-column settings grid when present.
tpl = tpl.replace('class="grid two vm-boot-iso-grid"', 'class="grid three vm-resource-boot-iso-grid"')
if tpl != original:
template_path.write_text(tpl)
if css_path.exists():
css = css_path.read_text()
original_css = css
if '.three {' not in css:
if '.two {' in css:
css = css.replace('.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }', '.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }\n.three { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 12px; }')
if '.three {' not in css:
css += '\n.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }\n'
else:
css += '\n.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }\n'
changed.append('three-column grid CSS added')
if '.resource-mini-grid' not in css:
css += '''
.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; } }
'''
changed.append('resource and boot-order CSS added')
if css != original_css:
css_path.write_text(css)
else:
changed.append('CSS skipped: app.css not found')
print('VM detail resource layout patch applied:')
for item in changed or ['already applied']:
print(f'- {item}')
-74
Просмотреть файл
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def vm_exists(name: str) -> bool:
if not name or not valid_vm_name(name):
return False
return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"]
'''
if 'def vm_exists(name: str) -> bool:' not in text:
marker = '\n\ndef list_vms() -> list[dict[str, str]]:'
if marker not in text:
raise SystemExit('list_vms marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('vm_exists helper added')
else:
changed.append('vm_exists helper already present')
old_sig = '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"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = '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"), replace_existing_disk: str = Form("0"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('replace_existing_disk added to signature')
elif 'replace_existing_disk: str = Form("0")' in text:
changed.append('replace_existing_disk already in signature')
else:
raise SystemExit('vm_create_submit signature marker not found')
old_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
new_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "replace_existing_disk": replace_existing_disk, "network_mode": network_mode, "bridge": bridge}'
if old_form in text:
text = text.replace(old_form, new_form, 1)
changed.append('replace_existing_disk stored in form')
elif '"replace_existing_disk": replace_existing_disk' in text:
changed.append('replace_existing_disk already stored in form')
old_disk_check = ''' disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
'''
new_disk_check = ''' disk_path = IMAGES_DIR / f"{name}.qcow2"
if vm_exists(name):
return vm_form_context(request, error=f"VM с именем {name} уже существует. Выбери другое имя или удали существующую VM.", form=form, status_code=400)
if disk_path.exists():
if replace_existing_disk == "1":
disk_path.unlink()
else:
return vm_form_context(request, error=f"Диск уже существует: {disk_path}. Это может быть остаток неудачной установки. Включи опцию «Заменить существующий диск», если VM с таким именем не нужна.", form=form, status_code=400)
profile = host_profile.load_host_profile()
'''
if old_disk_check in text:
text = text.replace(old_disk_check, new_disk_check, 1)
changed.append('disk replace logic added')
elif 'Заменить существующий диск' in text and 'replace_existing_disk == "1"' in text:
changed.append('disk replace logic already present')
else:
raise SystemExit('disk_path exists marker not found')
app_path.write_text(text)
print('vm disk replace patch applied:')
for item in changed:
print(f'- {item}')
-64
Просмотреть файл
@@ -1,64 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = '''
def bridge_exists(name: str) -> bool:
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
return False
return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]
'''
if 'def bridge_exists(name: str) -> bool:' not in text:
markers = [
'\n\ndef default_network_mode() -> str:',
'\n\ndef valid_vm_name(name: str) -> bool:',
'\n\ndef parse_virsh_list() -> list[dict[str, str]]:',
]
inserted = False
for marker in markers:
if marker in text:
text = text.replace(marker, helper + marker, 1)
changed.append('bridge_exists helper added')
inserted = True
break
if not inserted:
print('WARN: bridge helper marker not found, skip helper injection')
changed.append('bridge_exists helper skipped')
else:
changed.append('bridge_exists helper already present')
old_validation = ''' elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge."
else:
iso = Path(iso_path).resolve()
'''
new_validation = ''' elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge."
elif network_mode == "bridge" and 'bridge_exists' in globals() and not bridge_exists(bridge):
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
else:
iso = Path(iso_path).resolve()
'''
if old_validation in text:
text = text.replace(old_validation, new_validation, 1)
changed.append('bridge existence validation added')
elif 'Bridge {bridge} не найден на сервере' in text:
changed.append('bridge existence validation already present')
else:
print('WARN: bridge validation marker not found, skip validation injection')
changed.append('bridge validation skipped')
app_path.write_text(text)
print('vm network guard patch applied:')
for item in changed:
print(f'- {item}')
-101
Просмотреть файл
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
safe_proxy = '''# Virtuality noVNC console patch
async def proxy_vnc_to_websocket(reader: asyncio.StreamReader, websocket: WebSocket) -> None:
while True:
data = await reader.read(65536)
if not data:
break
try:
await websocket.send_bytes(data)
except (WebSocketDisconnect, RuntimeError, ConnectionError):
break
async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWriter) -> None:
while True:
try:
message = await websocket.receive()
except (WebSocketDisconnect, RuntimeError, ConnectionError):
break
if message.get("type") == "websocket.disconnect":
break
if message.get("bytes") is not None:
writer.write(message["bytes"])
elif message.get("text") is not None:
writer.write(message["text"].encode())
try:
await writer.drain()
except (RuntimeError, ConnectionError, BrokenPipeError):
break
'''
safe_gather = ''' tasks = [
asyncio.create_task(proxy_vnc_to_websocket(reader, websocket)),
asyncio.create_task(proxy_websocket_to_vnc(websocket, writer)),
]
try:
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
await asyncio.gather(*done, return_exceptions=True)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
'''
if 'Virtuality noVNC console patch' in text:
start = text.find('# Virtuality noVNC console patch')
marker = '\n\n@app.get("/login", response_class=HTMLResponse)'
end = text.find(marker, start)
if start != -1 and end != -1:
text = text[:start] + safe_proxy + text[end:]
old_gather = ''' try:
await asyncio.gather(proxy_vnc_to_websocket(reader, websocket), proxy_websocket_to_vnc(websocket, writer))
except (WebSocketDisconnect, asyncio.CancelledError, ConnectionError):
pass
finally:
writer.close()
await writer.wait_closed()
'''
if old_gather in text:
text = text.replace(old_gather, safe_gather, 1)
app_path.write_text(text)
print(f'noVNC console patch upgraded: {app_path}')
raise SystemExit(0)
text = text.replace('import crypt\n', 'import asyncio\nimport crypt\n', 1)
text = text.replace('from fastapi import FastAPI, Request, Form, UploadFile, File\n', 'from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect\n', 1)
text = text.replace('serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")\n', 'serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")\nconsole_serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-console")\n', 1)
text = text.replace('DEFAULT_BRIDGE = "br0"\n', 'DEFAULT_BRIDGE = "br0"\nNOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)\n', 1)
text = text.replace('app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")\n', 'app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")\nif NOVNC_DIR:\n app.mount("/novnc", StaticFiles(directory=str(NOVNC_DIR)), name="novnc")\n', 1)
old_get_user = '''def get_current_user(request: Request) -> str | None:\n token = request.cookies.get("virtuality_session")\n if not token:\n return None\n try:\n data = serializer.loads(token)\n except BadSignature:\n return None\n return AUTH_USER if data.get("user") == AUTH_USER else None\n'''
new_get_user = '''def user_from_session_token(token: str | None) -> str | None:\n if not token:\n return None\n try:\n data = serializer.loads(token)\n except BadSignature:\n return None\n return AUTH_USER if data.get("user") == AUTH_USER else None\n\n\ndef get_current_user(request: Request) -> str | None:\n return user_from_session_token(request.cookies.get("virtuality_session"))\n'''
text = text.replace(old_get_user, new_get_user, 1)
insert_after_vm_ip = '''def vm_vnc_display(name: str) -> str:\n return run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available"\n\n\ndef vnc_display_to_port(display: str) -> int | None:\n value = (display or "").strip()\n if not value or value == "not available":\n return None\n match = re.search(r":(\\d+)$", value)\n if not match:\n return None\n display_number = int(match.group(1))\n if display_number >= 5900:\n return display_number\n return 5900 + display_number\n\n\ndef console_info(name: str) -> dict[str, Any]:\n display = vm_vnc_display(name)\n port = vnc_display_to_port(display)\n has_novnc = bool(NOVNC_DIR and (NOVNC_DIR / "vnc.html").exists())\n token = None\n url = None\n if port and has_novnc:\n token = console_serializer.dumps({"vm": name, "port": port})\n url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=console/ws/{token}"\n return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}\n\n\n'''
text = text.replace('def vm_details(name: str) -> dict[str, Any]:\n', insert_after_vm_ip + 'def vm_details(name: str) -> dict[str, Any]:\n', 1)
text = text.replace('"vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",', '"vnc": vm_vnc_display(name),', 1)
text = text.replace('@app.get("/login", response_class=HTMLResponse)\n', safe_proxy + '\n@app.get("/login", response_class=HTMLResponse)\n', 1)
console_routes = '''\n\n@app.get("/vm/{name}/console", response_class=HTMLResponse)\ndef vm_console_page(request: Request, name: str):\n auth_redirect = require_auth(request)\n if auth_redirect:\n return auth_redirect\n if not valid_vm_name(name) or not vm_exists(name):\n return RedirectResponse(url="/", status_code=303)\n return templates.TemplateResponse("console.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})\n\n\n@app.websocket("/console/ws/{token}")\nasync def console_websocket(websocket: WebSocket, token: str):\n if user_from_session_token(websocket.cookies.get("virtuality_session")) != AUTH_USER:\n await websocket.close(code=1008)\n return\n try:\n payload = console_serializer.loads(token)\n vm_name = payload.get("vm")\n target_port = int(payload.get("port"))\n except Exception:\n await websocket.close(code=1008)\n return\n if not valid_vm_name(vm_name) or not vm_exists(vm_name) or target_port < 5900 or target_port > 5999:\n await websocket.close(code=1008)\n return\n await websocket.accept()\n try:\n reader, writer = await asyncio.open_connection("127.0.0.1", target_port)\n except Exception:\n await websocket.close(code=1011)\n return\n''' + safe_gather.replace('\n', '\n')
text = text.replace('\n\n@app.get("/vm/{name}", response_class=HTMLResponse)\n', console_routes + '\n\n@app.get("/vm/{name}", response_class=HTMLResponse)\n', 1)
app_path.write_text(text)
print(f'noVNC console patch applied: {app_path}')
+95
Просмотреть файл
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
# ==========================================================
# Virtuality first boot setup
# Runs once on a system installed from a Virtuality image:
# installs KVM/libvirt/Cockpit, the web panel and helpers
# from the bundled source in /opt/virtuality/source.
# ==========================================================
SOURCE_DIR="${VIRTUALITY_SOURCE_DIR:-/opt/virtuality/source}"
IMAGE_ENV="/etc/virtuality/image.env"
STATE_DIR="/var/lib/virtuality"
DONE_FLAG="${STATE_DIR}/.firstboot-done"
LOG_DIR="/var/log/virtuality"
LOG_FILE="${LOG_DIR}/firstboot.log"
APT_LOCK_CONF="/etc/apt/apt.conf.d/90virtuality-lock-timeout"
ISSUE_FILE="/etc/issue.d/virtuality.issue"
NETWORK_WAIT_SECONDS="${VIRTUALITY_NETWORK_WAIT_SECONDS:-300}"
mkdir -p "$LOG_DIR" "$STATE_DIR" "$(dirname "$ISSUE_FILE")"
exec > >(tee -a "$LOG_FILE") 2>&1
now() { date '+%Y-%m-%d %H:%M:%S'; }
say() { echo "[$(now)] [virtuality-firstboot] $*"; }
if [[ -f "$DONE_FLAG" ]]; then
say "already completed, nothing to do"
exit 0
fi
if [[ -f "$IMAGE_ENV" ]]; then
set -a
# shellcheck disable=SC1090
. "$IMAGE_ENV"
set +a
fi
first_human_user() {
getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {print $1; exit}'
}
AUTH_USER="${VIRTUALITY_USER:-$(first_human_user)}"
AUTH_USER="${AUTH_USER:-root}"
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
export VIRTUALITY_USER="$AUTH_USER"
export VIRTUALITY_AUTH_USER="$AUTH_USER"
export VIRTUALITY_WEB_PORT="$WEB_PORT"
export VIRTUALITY_AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-1}"
# Disk/RAM limits are reported by vhealth; they must not block an image install.
export VIRTUALITY_SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-1}"
export DEBIAN_FRONTEND=noninteractive
export TERM="${TERM:-dumb}"
cat > "$ISSUE_FILE" <<'EOF'
Virtuality: first boot setup is running. Progress: sudo journalctl -fu virtuality-firstboot
EOF
say "source: $SOURCE_DIR"
say "panel user: $AUTH_USER, port: $WEB_PORT"
[[ -f "$SOURCE_DIR/install_virtuality_node.sh" ]] || { say "ERROR: Virtuality source not found in $SOURCE_DIR"; exit 1; }
# unattended-upgrades often holds the dpkg lock right after the first boot.
printf 'DPkg::Lock::Timeout "900";\n' > "$APT_LOCK_CONF"
say "waiting for network (up to ${NETWORK_WAIT_SECONDS}s)"
deadline=$(( $(date +%s) + NETWORK_WAIT_SECONDS ))
until ip route show default 2>/dev/null | grep -q . && getent hosts archive.ubuntu.com >/dev/null 2>&1; do
if (( $(date +%s) >= deadline )); then
say "ERROR: no network with DNS. Configure networking; the setup will be retried automatically"
exit 1
fi
sleep 5
done
say "network is ready"
cd "$SOURCE_DIR"
say "step 1/4: virtualization node (KVM, libvirt, Cockpit)"
bash ./install_virtuality_node.sh
say "step 2/4: healthcheck command"
bash ./scripts/install_healthcheck_command.sh
say "step 3/4: web panel"
bash ./scripts/install_web_panel.sh
say "step 4/4: console dashboard"
bash ./scripts/install_console_dashboard.sh
touch "$DONE_FLAG"
cat > "$ISSUE_FILE" <<EOF
Virtuality web panel: http://\4:${WEB_PORT} (login: ${AUTH_USER})
Cockpit: https://\4:9090
EOF
systemctl disable virtuality-firstboot.service >/dev/null 2>&1 || true
say "completed. Web panel: http://$(hostname -I | awk '{print $1}'):${WEB_PORT} login: ${AUTH_USER}"
+133
Просмотреть файл
@@ -0,0 +1,133 @@
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
WEB_DIR = ROOT / "web"
sys.path.insert(0, str(WEB_DIR))
os.environ.setdefault("VIRTUALITY_AUTH_USER", "tester")
os.environ.setdefault("VIRTUALITY_SESSION_SECRET", "test-secret-" + "0" * 32)
DOMINFO = """Id: 1
Name: web01
UUID: 6c1b3c1e-0000-0000-0000-000000000001
OS Type: hvm
State: running
CPU(s): 2
Max memory: 2097152 KiB
Used memory: 2097152 KiB
Persistent: yes
Autostart: enable
"""
DUMPXML = """<domain type='kvm'>
<name>web01</name>
<memory unit='KiB'>2097152</memory>
<vcpu placement='static'>2</vcpu>
<os><type arch='x86_64' machine='pc-q35'>hvm</type><boot dev='hd'/></os>
<devices>
<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>
</devices>
</domain>
"""
def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""}
def fake_run_cmd(cmd, timeout=12, **_kwargs):
joined = " ".join(cmd)
if cmd[:2] == ["virsh", "list"]:
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
if cmd[:2] == ["virsh", "dominfo"]:
return fake_result(DOMINFO) if cmd[-1] in ("web01", "db01") else fake_result(ok=False, stderr="failed to get domain")
if cmd[:2] == ["virsh", "dumpxml"]:
return fake_result(DUMPXML)
if cmd[:2] == ["virsh", "domstate"]:
return fake_result("running")
if cmd[:2] == ["virsh", "domifaddr"]:
return fake_result(" Name MAC address Protocol Address\n vnet0 52:54:00:aa:bb:cc ipv4 192.168.100.51/24")
if cmd[:2] == ["virsh", "domiflist"]:
return fake_result(" Interface Type Source Model MAC\n vnet0 network virtuality-nat virtio 52:54:00:aa:bb:cc")
if cmd[:2] == ["virsh", "vncdisplay"]:
return fake_result(":0")
if cmd[:2] == ["virsh", "pool-list"]:
return fake_result(" Name State Autostart\n---\n virtuality-images active yes")
if cmd[:2] == ["virsh", "net-list"]:
return fake_result(" Name State Autostart Persistent\n---\n virtuality-nat active yes yes")
if cmd[:2] == ["systemctl", "is-active"]:
return fake_result("active")
if cmd[:1] == ["hostname"]:
return fake_result("192.168.1.10" if "-I" in cmd else "virtuality-test")
if cmd[:3] == ["ip", "route", "show"]:
return fake_result("default via 192.168.1.1 dev eth0")
if cmd[:1] in (["uptime"], ["uname"]):
return fake_result("up 1 hour" if cmd[0] == "uptime" else "6.8.0")
if "nft" in joined or "iptables" in joined or "ufw" in joined or "sysctl" in joined:
return fake_result()
return fake_result()
@pytest.fixture()
def data_dirs(tmp_path, monkeypatch):
import app
import host_profile
import network_core
import update_core
dirs = {
"iso": tmp_path / "iso",
"images": tmp_path / "images",
"disk_images": tmp_path / "disk-images",
"operations": tmp_path / "operations",
"network": tmp_path / "network",
"config": tmp_path / "config",
"nft": tmp_path / "nft",
"update": tmp_path / "update",
}
for path in dirs.values():
path.mkdir()
monkeypatch.setattr(app, "ISO_DIR", dirs["iso"])
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"])
monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"])
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
monkeypatch.setattr(network_core, "PORT_FORWARDS_FILE", dirs["network"] / "port_forwards.json")
monkeypatch.setattr(network_core, "NAT_XML_FILE", dirs["network"] / "virtuality-nat.xml")
monkeypatch.setattr(network_core, "NFT_FILE", dirs["nft"] / "virtuality.nft")
monkeypatch.setattr(network_core, "enable_ip_forward", lambda: None)
monkeypatch.setattr(network_core, "disable_rp_filter", lambda: None)
monkeypatch.setattr(host_profile, "PROFILE_FILE", dirs["config"] / "host_profile.json")
monkeypatch.setattr(update_core, "STATE_DIR", dirs["update"])
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
for module in (app, network_core, host_profile):
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
return dirs
@pytest.fixture()
def client(data_dirs):
from fastapi.testclient import TestClient
import app
app.login_throttle = app.auth.LoginThrottle()
return TestClient(app.app, base_url="http://panel.test")
@pytest.fixture()
def logged_in(client, monkeypatch):
import app
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: password == "correct")
response = client.post("/login", data={"username": "tester", "password": "correct"}, follow_redirects=False)
assert response.status_code == 303
return client
+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);
+188
Просмотреть файл
@@ -0,0 +1,188 @@
from urllib.parse import unquote
import pytest
import app
PAGES = ["/", "/help", "/host", "/iso", "/disk-images", "/network", "/operations", "/logs", "/update", "/vm/create", "/vm/web01", "/vm/web01/console"]
def test_healthz_is_public(client):
response = client.get("/healthz")
assert response.status_code == 200
assert response.json()["ok"] is True
assert response.json()["version"] == (app.BASE_DIR.parent / "VERSION").read_text().strip()
def test_security_headers(client):
response = client.get("/login")
assert response.headers["x-frame-options"] == "SAMEORIGIN"
assert response.headers["x-content-type-options"] == "nosniff"
@pytest.mark.parametrize("path", PAGES)
def test_pages_require_login(client, path):
response = client.get(path, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
@pytest.mark.parametrize("path", PAGES)
def test_pages_render(logged_in, path):
response = logged_in.get(path)
assert response.status_code == 200, response.text[:500]
def test_wrong_user_is_rejected(client, monkeypatch):
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: True)
response = client.post("/login", data={"username": "intruder", "password": "x"}, follow_redirects=False)
assert response.status_code == 401
def test_login_throttle(client, monkeypatch):
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: False)
for _ in range(5):
assert client.post("/login", data={"username": "tester", "password": "bad"}).status_code == 401
response = client.post("/login", data={"username": "tester", "password": "bad"})
assert response.status_code == 429
assert int(response.headers["retry-after"]) > 0
def test_session_cookie_flags(client, monkeypatch):
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: True)
response = client.post("/login", data={"username": "tester", "password": "x"}, follow_redirects=False)
cookie = response.headers["set-cookie"].lower()
assert "httponly" in cookie and "samesite=lax" in cookie
def test_forged_cookie_is_rejected(client):
client.cookies.set("virtuality_session", "forged.token.value")
assert client.get("/", follow_redirects=False).status_code == 303
def test_cross_origin_post_blocked(logged_in):
response = logged_in.post("/vm/web01/start", headers={"Origin": "http://evil.example"}, follow_redirects=False)
assert response.status_code == 403
def test_same_origin_post_allowed(logged_in):
response = logged_in.post("/vm/web01/start", headers={"Origin": "http://panel.test"}, follow_redirects=False)
assert response.status_code == 303
def test_vm_action_rejects_bad_name(logged_in):
assert logged_in.post("/vm/-bad/start", follow_redirects=False).status_code == 400
def test_vm_action_unknown(logged_in):
assert logged_in.post("/vm/web01/format-disk", follow_redirects=False).status_code == 400
def test_vm_details_include_autostart(data_dirs):
details = app.vm_details("web01")
assert details["autostart_enabled"] is True
assert details["autostart_label"] == "enabled"
def test_vm_detail_shows_autostart_enabled(logged_in):
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):
monkeypatch.setattr(app, "apply_vm_boot_order", lambda name, order: (True, "Готово: диск & ISO?"))
response = logged_in.post("/vm/web01/boot-order", data={"boot_order": "disk"}, follow_redirects=False)
location = response.headers["location"]
assert " " not in location and "&" not in location.split("?", 1)[1]
assert unquote(location.split("boot_message=", 1)[1]) == "Готово: диск & ISO?"
def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch):
iso = data_dirs["iso"] / "debian.iso"
iso.write_bytes(b"iso")
captured = {}
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(operation=operation, cmd=cmd))
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
form = {"name": "new-vm", "memory": 2048, "vcpus": 2, "disk_size": 20, "iso_path": str(iso), "source_type": "iso", "guest_arch": "x86_64", "boot_order": "auto", "network_mode": "nat"}
response = logged_in.post("/vm/create", data=form, follow_redirects=False)
assert response.status_code == 303, response.text[:500]
cmd = captured["cmd"]
assert cmd[0] == "virt-install"
assert "--cdrom" in cmd and str(iso) in cmd
assert "network=virtuality-nat,model=virtio" in cmd
assert captured["operation"]["boot_order"] == "cdrom_disk"
def test_vm_create_validation_error(logged_in):
form = {"name": "x", "memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "network_mode": "nat"}
response = logged_in.post("/vm/create", data=form)
assert response.status_code == 400
def test_iso_upload_and_delete(logged_in, data_dirs, monkeypatch):
response = logged_in.post("/iso/upload", files={"iso_file": ("My Distro 1.0.iso", b"data", "application/octet-stream")}, follow_redirects=False)
assert response.status_code == 303
assert (data_dirs["iso"] / "My-Distro-1.0.iso").read_bytes() == b"data"
logged_in.post("/iso/My-Distro-1.0.iso/delete", follow_redirects=False)
assert not (data_dirs["iso"] / "My-Distro-1.0.iso").exists()
def test_iso_upload_rejects_other_types(logged_in):
response = logged_in.post("/iso/upload", files={"iso_file": ("evil.sh", b"#!/bin/sh", "text/plain")})
assert response.status_code == 400
def test_iso_delete_path_traversal(logged_in, data_dirs, tmp_path):
victim = tmp_path / "victim.iso"
victim.write_bytes(b"keep")
logged_in.post("/iso/..%2Fvictim.iso/delete", follow_redirects=False)
assert victim.exists()
def test_port_forward_add(logged_in, data_dirs):
form = {"vm_name": "web01", "guest_ip": "192.168.100.51", "external_port": "2222", "guest_port": "22", "protocol": "tcp"}
response = logged_in.post("/network/forward/add", data=form, follow_redirects=False)
assert response.status_code == 303
rules = (data_dirs["nft"] / "virtuality.nft").read_text()
assert "tcp dport 2222 dnat to 192.168.100.51:22" in rules
def test_api_requires_auth(client):
for path in ("/api/health", "/api/operations", "/live/status", "/api/logs"):
assert client.get(path).status_code == 401
def test_live_status(logged_in):
data = logged_in.get("/live/status").json()
assert data["ok"] is True
assert {vm["name"] for vm in data["vms"]} == {"web01", "db01"}
+49
Просмотреть файл
@@ -0,0 +1,49 @@
import pytest
import auth
# SHA-512 crypt hash of "s3cret" with salt "virtualitysalt".
SHA512_HASH = "$6$virtualitysalt$"
@pytest.fixture()
def password_hash():
computed = auth.crypt_hash("s3cret", SHA512_HASH)
assert computed and computed.startswith(SHA512_HASH)
return computed
def write_shadow(tmp_path, monkeypatch, entries):
shadow = tmp_path / "shadow"
shadow.write_text("".join(f"{user}:{pw}:19000:0:99999:7:::\n" for user, pw in entries))
monkeypatch.setattr(auth, "SHADOW_FILE", shadow)
monkeypatch.setattr(auth, "_stdlib_spwd", None)
def test_libcrypt_fallback_matches(password_hash, monkeypatch):
monkeypatch.setattr(auth, "_stdlib_crypt", None)
assert auth.crypt_hash("s3cret", password_hash) == password_hash
assert auth.crypt_hash("wrong", password_hash) != password_hash
def test_verify_password_with_shadow_file(password_hash, tmp_path, monkeypatch):
write_shadow(tmp_path, monkeypatch, [("root", "*"), ("admin", password_hash), ("locked", "!" + password_hash)])
monkeypatch.setattr(auth, "_stdlib_crypt", None)
assert auth.verify_password("admin", "s3cret")
assert not auth.verify_password("admin", "wrong")
assert not auth.verify_password("admin", "")
assert not auth.verify_password("root", "anything")
assert not auth.verify_password("locked", "s3cret")
assert not auth.verify_password("missing", "s3cret")
def test_throttle_locks_and_resets():
throttle = auth.LoginThrottle(max_failures=3, window_seconds=60, lockout_seconds=60)
for _ in range(2):
throttle.record_failure("1.2.3.4")
assert throttle.retry_after("1.2.3.4") == 0
throttle.record_failure("1.2.3.4")
assert throttle.retry_after("1.2.3.4") > 0
assert throttle.retry_after("5.6.7.8") == 0
throttle.record_success("1.2.3.4")
assert throttle.retry_after("1.2.3.4") == 0
+104
Просмотреть файл
@@ -0,0 +1,104 @@
import json
from pathlib import Path
import pytest
import network_core
import restore_network
import update_core
def test_parse_port_range():
assert network_core.parse_port_range("80") == (80, 80)
assert network_core.parse_port_range("10000-10010") == (10000, 10010)
assert network_core.parse_port_range("10000:10010") == (10000, 10010)
for bad in ("", "0", "70000", "20-10", "abc", "80;rm"):
with pytest.raises(network_core.NetworkError):
network_core.parse_port_range(bad)
def test_add_port_forward_rejects_overlap(data_dirs):
network_core.add_port_forward("web01", "192.168.100.51", "8000-8010", "8000-8010", "tcp")
with pytest.raises(network_core.NetworkError):
network_core.add_port_forward("web01", "192.168.100.52", "8005", "80", "tcp")
network_core.add_port_forward("web01", "192.168.100.52", "8005", "80", "udp")
def test_add_port_forward_rejects_mismatched_ranges(data_dirs):
with pytest.raises(network_core.NetworkError):
network_core.add_port_forward("web01", "192.168.100.51", "8000-8010", "80", "tcp")
def test_render_nft_rules_range(data_dirs):
network_core.add_port_forward("web01", "192.168.100.51", "10000-10002", "20000-20002", "udp")
rules = network_core.render_nft_rules()
assert 'iifname "eth0" udp dport 10000-10002 dnat to 192.168.100.51:20000-20002' in rules
assert "masquerade" in rules
def test_restore_network_noop_without_config(data_dirs, capsys):
assert restore_network.main() == 0
assert "nothing to restore" in capsys.readouterr().out
def test_restore_network_applies_rules(data_dirs):
data_dirs["network"].joinpath("port_forwards.json").write_text(json.dumps([
{"id": "1", "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"},
]))
assert restore_network.main() == 0
assert "dport 2222 dnat to 192.168.100.51:22" in (data_dirs["nft"] / "virtuality.nft").read_text()
def test_version_tuple_ordering():
assert update_core.version_tuple("0.9.6") < update_core.version_tuple("0.10.0")
assert update_core.version_tuple("v1.0") == update_core.version_tuple("1.0.0")
def test_missing_versions():
manifest = {"versions": [{"version": v} for v in ("0.9.5", "0.9.6", "0.10.0", "0.10.1")]}
missed = update_core.missing_versions("0.9.6", "0.10.1", manifest)
assert [item["version"] for item in missed] == ["0.10.0", "0.10.1"]
def test_versions_manifest_matches_version_file():
root = Path(__file__).resolve().parents[1]
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
+26
Просмотреть файл
@@ -1,5 +1,31 @@
{
"versions": [
{
"version": "0.10.0",
"date": "2026-09-25",
"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",
"Исправлено: центр обновлений и автообновление предлагали откат на старую версию",
"Проверка пароля работает на Python 3.13+ без модулей crypt и spwd",
"Ограничение попыток входа, срок жизни сессии, защита от cross-origin POST",
"Правила проброса портов восстанавливаются после перезагрузки сервера",
"Настройки ноды сохраняются в /var/lib/virtuality/config/web.env и не сбрасываются обновлением",
"Публичный /healthz для мониторинга, автотесты и GitHub Actions",
"Версия обновлена до 0.10.0"
]
},
{
"version": "0.8.61",
"date": "2026-05-07",
Разница между файлами не показана из-за своего большого размера Загрузить разницу
+120
Просмотреть файл
@@ -0,0 +1,120 @@
"""Linux password verification for the Virtuality panel.
The stdlib `crypt` and `spwd` modules are deprecated in Python 3.11+ and
removed in 3.13 (Debian 13, Ubuntu 25.04+). This module uses them when they
exist and otherwise falls back to libcrypt via ctypes and /etc/shadow.
"""
import ctypes
import ctypes.util
import secrets
import threading
import time
import warnings
from pathlib import Path
SHADOW_FILE = Path("/etc/shadow")
LOCKED_HASHES = {"", "!", "*", "!!"}
_crypt_lock = threading.Lock()
_libcrypt = None
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
import crypt as _stdlib_crypt # type: ignore[import-not-found]
except ImportError:
_stdlib_crypt = None
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
import spwd as _stdlib_spwd # type: ignore[import-not-found]
except ImportError:
_stdlib_spwd = None
def _load_libcrypt():
global _libcrypt
if _libcrypt is None:
name = ctypes.util.find_library("crypt") or "libcrypt.so.1"
lib = ctypes.CDLL(name)
lib.crypt.restype = ctypes.c_char_p
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
_libcrypt = lib
return _libcrypt
def crypt_hash(password: str, salt: str) -> str | None:
# crypt(3) returns a pointer to a static buffer, so calls are serialized.
with _crypt_lock:
if _stdlib_crypt is not None:
return _stdlib_crypt.crypt(password, salt)
try:
result = _load_libcrypt().crypt(password.encode(), salt.encode())
except OSError:
return None
return result.decode() if result else None
def shadow_hash(username: str) -> str | None:
if _stdlib_spwd is not None:
try:
return _stdlib_spwd.getspnam(username).sp_pwdp
except (KeyError, PermissionError):
return None
try:
for line in SHADOW_FILE.read_text().splitlines():
parts = line.split(":")
if len(parts) > 1 and parts[0] == username:
return parts[1]
except OSError:
return None
return None
def verify_password(username: str, password: str) -> bool:
if not username or not password:
return False
stored_hash = shadow_hash(username)
if stored_hash is None or stored_hash in LOCKED_HASHES or stored_hash.startswith("!"):
return False
computed = crypt_hash(password, stored_hash)
if not computed:
return False
return secrets.compare_digest(computed, stored_hash)
class LoginThrottle:
"""In-memory brute-force protection keyed by client address."""
def __init__(self, max_failures: int = 5, window_seconds: int = 300, lockout_seconds: int = 300):
self.max_failures = max_failures
self.window_seconds = window_seconds
self.lockout_seconds = lockout_seconds
self._failures: dict[str, list[float]] = {}
self._locked_until: dict[str, float] = {}
self._lock = threading.Lock()
def retry_after(self, key: str) -> int:
with self._lock:
until = self._locked_until.get(key, 0.0)
remaining = until - time.monotonic()
if remaining <= 0:
self._locked_until.pop(key, None)
return 0
return int(remaining) + 1
def record_failure(self, key: str) -> None:
now = time.monotonic()
with self._lock:
recent = [ts for ts in self._failures.get(key, []) if now - ts < self.window_seconds]
recent.append(now)
if len(recent) >= self.max_failures:
self._locked_until[key] = now + self.lockout_seconds
recent = []
self._failures[key] = recent
def record_success(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
self._locked_until.pop(key, None)
+21 -16
Просмотреть файл
@@ -7,6 +7,7 @@ from typing import Any
PROFILE_FILE = Path('/var/lib/virtuality/config/host_profile.json')
DEFAULT_BRIDGE = 'br0'
PROFILE_SCHEMA = 3
def run_cmd(cmd: list[str], timeout: int = 5) -> dict[str, Any]:
@@ -34,9 +35,12 @@ def detect_board_model() -> str:
if value:
return value
cpuinfo = read_text('/proc/cpuinfo')
for line in cpuinfo.splitlines():
if line.lower().startswith(('model', 'hardware')) and ':' in line:
return line.split(':', 1)[1].strip()
for prefix in ('model name', 'hardware', 'model'):
for line in cpuinfo.splitlines():
key, _, value = line.partition(':')
value = value.strip()
if key.strip().lower() == prefix and value and not value.isdigit():
return value
return 'unknown'
@@ -70,7 +74,7 @@ def classify_profile(arch: str, model: str) -> dict[str, str]:
if 'raspberry pi' in low:
return {
'profile': 'raspberry-arm64',
'label': 'Raspberry Pi ARM64 Edge Node',
'label': 'Raspberry Pi (ARM64)',
'recommended_network': 'nat',
'recommended_guest_arch': 'aarch64',
'recommended_vm_mode': 'arm64-cloud-image',
@@ -78,21 +82,21 @@ def classify_profile(arch: str, model: str) -> dict[str, str]:
if 'orange pi 5' in low or 'orangepi 5' in low or 'rk3588' in low or 'rockchip' in low:
return {
'profile': 'orangepi5-arm64',
'label': 'Orange Pi 5 ARM64 Edge Node',
'label': 'Orange Pi 5 (ARM64)',
'recommended_network': 'nat',
'recommended_guest_arch': 'aarch64',
'recommended_vm_mode': 'arm64-cloud-image',
}
return {
'profile': 'generic-arm64',
'label': 'Generic ARM64 Edge Node',
'label': 'ARM64-сервер',
'recommended_network': 'nat',
'recommended_guest_arch': 'aarch64',
'recommended_vm_mode': 'arm64-cloud-image',
}
return {
'profile': 'x86_64',
'label': 'x86_64 KVM/QEMU Node',
'label': 'Сервер x86_64',
'recommended_network': 'bridge',
'recommended_guest_arch': 'x86_64',
'recommended_vm_mode': 'x86_64-iso',
@@ -148,17 +152,18 @@ def detect_host_profile() -> dict[str, Any]:
]),
}
checks = []
checks.append({'name': '/dev/kvm', 'ok': data['kvm_device'], 'level': 'ok' if data['kvm_device'] else 'warn', 'hint': 'KVM недоступен. На физическом сервере включи Intel VT-x / AMD-V в BIOS/UEFI; на VPS проверь nested virtualization. Пока доступен медленный QEMU fallback.'})
checks.append({'name': 'CPU vmx/svm', 'ok': virtualization_flags > 0, 'level': 'ok' if virtualization_flags > 0 else 'warn', 'hint': 'CPU-флаги vmx/svm не видны. Для реального сервера это обычно значит, что виртуализация выключена в BIOS/UEFI.'})
checks.append({'name': 'Аппаратное ускорение (KVM)', 'ok': data['kvm_device'], 'level': 'ok' if data['kvm_device'] else 'warn', 'hint': 'KVM недоступен. На физическом сервере включи Intel VT-x / AMD-V в BIOS/UEFI; на VPS проверь nested virtualization. Пока доступен медленный QEMU fallback.'})
checks.append({'name': 'Поддержка виртуализации процессором', 'ok': virtualization_flags > 0, 'level': 'ok' if virtualization_flags > 0 else 'warn', 'hint': 'CPU-флаги vmx/svm не видны. Для реального сервера это обычно значит, что виртуализация выключена в BIOS/UEFI.'})
if is_arm:
checks.append({'name': 'qemu-system-aarch64', 'ok': data['qemu_system_aarch64'], 'level': 'ok' if data['qemu_system_aarch64'] else 'err', 'hint': 'Пакет qemu-system-arm / qemu-system-aarch64.'})
checks.append({'name': 'AArch64 UEFI', 'ok': data['uefi_aarch64_hint'], 'level': 'ok' if data['uefi_aarch64_hint'] else 'warn', 'hint': 'Пакет qemu-efi-aarch64 или AAVMF.'})
checks.append({'name': 'Эмулятор ARM64 (QEMU)', 'ok': data['qemu_system_aarch64'], 'level': 'ok' if data['qemu_system_aarch64'] else 'err', 'hint': 'Пакет qemu-system-arm / qemu-system-aarch64.'})
checks.append({'name': 'Загрузчик UEFI для ARM64', 'ok': data['uefi_aarch64_hint'], 'level': 'ok' if data['uefi_aarch64_hint'] else 'warn', 'hint': 'Пакет qemu-efi-aarch64 или AAVMF.'})
else:
checks.append({'name': 'qemu-system-x86_64', 'ok': data['qemu_system_x86_64'], 'level': 'ok' if data['qemu_system_x86_64'] else 'err', 'hint': 'Пакет qemu-system-x86.'})
checks.append({'name': 'virt-install', 'ok': data['virt_install'], 'level': 'ok' if data['virt_install'] else 'err', 'hint': 'Пакет virtinst.'})
checks.append({'name': 'nftables', 'ok': data['nft'], 'level': 'ok' if data['nft'] else 'err', 'hint': 'Пакет nftables для NAT port forwarding.'})
checks.append({'name': f'bridge {DEFAULT_BRIDGE}', 'ok': bridge_available, 'level': 'ok' if bridge_available else 'warn', 'hint': 'Если br0 отсутствует, Virtuality будет использовать VPS NAT Router / virtuality-nat.'})
checks.append({'name': 'Эмулятор x86_64 (QEMU)', 'ok': data['qemu_system_x86_64'], 'level': 'ok' if data['qemu_system_x86_64'] else 'err', 'hint': 'Пакет qemu-system-x86.'})
checks.append({'name': 'Создание машин (virt-install)', 'ok': data['virt_install'], 'level': 'ok' if data['virt_install'] else 'err', 'hint': 'Пакет virtinst.'})
checks.append({'name': 'Брандмауэр nftables', 'ok': data['nft'], 'level': 'ok' if data['nft'] else 'err', 'hint': 'Пакет nftables для NAT port forwarding.'})
checks.append({'name': f'Сетевой мост {DEFAULT_BRIDGE}', 'ok': bridge_available, 'level': 'ok' if bridge_available else 'warn', 'hint': 'Если br0 отсутствует, Virtuality будет использовать VPS NAT Router / virtuality-nat.'})
data['checks'] = checks
data['schema'] = PROFILE_SCHEMA
data['ready'] = data['virt_install'] and (data['qemu_system_aarch64'] if is_arm else data['qemu_system_x86_64']) and data['nft']
return data
@@ -172,7 +177,7 @@ def load_host_profile() -> dict[str, Any]:
if PROFILE_FILE.exists():
try:
profile = json.loads(PROFILE_FILE.read_text())
if 'bridge_available' not in profile or 'virtualization_mode' not in profile or any('level' not in item for item in profile.get('checks', [])):
if profile.get('schema') != PROFILE_SCHEMA or 'bridge_available' not in profile or 'virtualization_mode' not in profile or any('level' not in item for item in profile.get('checks', [])):
profile = detect_host_profile()
save_host_profile(profile)
return profile
+66 -12
Просмотреть файл
@@ -72,6 +72,18 @@ def iptables_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
def iptables_dnat_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
def successful_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in results if item.get('ok')]
def failed_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in results if not item.get('ok')]
def port_label(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
@@ -128,6 +140,20 @@ def external_interface() -> str:
return match.group(1) if match else 'eth0'
def route_interface_for_ip(ip: str) -> str:
if not valid_ip(ip):
return NAT_BRIDGE
result = run_cmd(['ip', 'route', 'get', ip], timeout=5)
if not result['ok']:
return NAT_BRIDGE
match = re.search(r'\bdev\s+([^\s]+)', result['stdout'])
return match.group(1) if match else NAT_BRIDGE
def forward_guest_interface(item: dict[str, Any]) -> str:
return route_interface_for_ip(str(item.get('guest_ip', '')))
def ip_forward_state() -> str:
path = Path('/proc/sys/net/ipv4/ip_forward')
if not path.exists():
@@ -352,12 +378,13 @@ def delete_port_forward(forward_id: str) -> None:
def render_nft_rules() -> str:
ext = external_interface()
forwards = load_port_forwards()
lines = [
'table ip virtuality {',
' chain prerouting {',
' type nat hook prerouting priority dstnat; policy accept;',
]
for item in load_port_forwards():
for item in forwards:
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
lines.append(f" iifname \"{ext}\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
@@ -366,11 +393,22 @@ def render_nft_rules() -> str:
' chain postrouting {',
' type nat hook postrouting priority srcnat; policy accept;',
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' ip daddr {item["guest_ip"]} oifname "{guest_iface}" masquerade')
lines += [
' }',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
f' ip saddr {NAT_SUBNET} accept',
f' ip daddr {NAT_SUBNET} accept',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' iifname "{ext}" oifname "{guest_iface}" ip daddr {item["guest_ip"]} accept')
lines.append(f' iifname "{guest_iface}" oifname "{ext}" ip saddr {item["guest_ip"]} ct state established,related accept')
lines += [
' }',
'}',
]
@@ -392,7 +430,7 @@ def apply_ufw_route_rules(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
cmd = [
'ufw', 'route', 'allow',
'in', 'on', ext,
'out', 'on', NAT_BRIDGE,
'out', 'on', forward_guest_interface(item),
'to', item['guest_ip'],
'port', guest_port,
'proto', item['protocol'],
@@ -429,10 +467,13 @@ def apply_iptables_fallback(items: list[dict[str, Any]]) -> list[dict[str, Any]]
guest_ip = item['guest_ip']
external_port = iptables_port_value(item['external_port_start'], item['external_port_end'])
guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end'])
guest_to = f"{guest_ip}:{iptables_port_value(item['guest_port_start'], item['guest_port_end'])}"
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', NAT_BRIDGE, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
guest_to_port = iptables_dnat_port_value(item['guest_port_start'], item['guest_port_end'])
guest_to = f"{guest_ip}:{guest_to_port}"
guest_iface = forward_guest_interface(item)
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, '-j', 'MASQUERADE']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-s', NAT_SUBNET, '-o', ext, '-j', 'MASQUERADE']))
return results
@@ -443,16 +484,29 @@ def apply_port_forwards() -> dict[str, Any]:
disable_rp_filter()
items = load_port_forwards()
NFT_FILE.write_text(render_nft_rules())
run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
result = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
if not result['ok']:
raise NetworkError(result['stderr'] or result['stdout'] or 'Не удалось применить nftables-правила')
nft_delete = run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
ufw_results = apply_ufw_route_rules(items)
iptables_results = apply_iptables_fallback(items)
iptables_ok = bool(successful_results(iptables_results)) or not items
if not nft_apply['ok'] and not iptables_ok:
details = [
nft_apply.get('stderr') or nft_apply.get('stdout') or 'nftables не применился',
*[
item.get('stderr') or item.get('stdout') or item.get('cmd', 'iptables rule failed')
for item in failed_results(iptables_results)
],
]
raise NetworkError('Не удалось применить правила проброса: ' + ' | '.join([d for d in details if d]))
return {
'ok': True,
'ok': nft_apply['ok'] or iptables_ok,
'file': str(NFT_FILE),
'rules': render_nft_rules(),
'nft_delete': nft_delete,
'nft_apply': nft_apply,
'ufw': ufw_results,
'iptables': iptables_results,
}
@@ -505,7 +559,7 @@ def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, pr
nft_has_range_rule = bool(matching_forward and vm_ip and matching_forward['external_port_label'] in nft_text and f"{vm_ip}:{matching_forward['guest_port_label']}" in nft_text)
nft_has_rule = nft_has_single_rule or nft_has_range_rule
iptables_has_prerouting = bool(vm_ip and f'--dport {int(external_port)} -j DNAT --to-destination {vm_ip}:{int(guest_port)}' in ipt_nat_text)
iptables_has_range_prerouting = bool(matching_forward and vm_ip and matching_forward['external_port_label'].replace('-', ':') in ipt_nat_text and f"{vm_ip}:{matching_forward['guest_port_label'].replace('-', ':')}" in ipt_nat_text)
iptables_has_range_prerouting = bool(matching_forward and vm_ip and matching_forward['external_port_label'].replace('-', ':') in ipt_nat_text and (f"{vm_ip}:{matching_forward['guest_port_label']}" in ipt_nat_text or f"{vm_ip}:{matching_forward['guest_port_label'].replace('-', ':')}" in ipt_nat_text))
iptables_has_prerouting = iptables_has_prerouting or iptables_has_range_prerouting
iptables_has_forward = bool(vm_ip and f'-d {vm_ip}/32' in ipt_forward_text and (f'--dport {int(guest_port)}' in ipt_forward_text or (matching_forward and matching_forward['guest_port_label'].replace('-', ':') in ipt_forward_text)))
ufw_has_route = bool(vm_ip and vm_ip in ufw_text and (str(int(guest_port)) in ufw_text or (matching_forward and matching_forward['guest_port_label'].replace('-', ':') in ufw_text)))
@@ -545,7 +599,7 @@ def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, pr
'checks': checks,
'commands': {
'watch_external': f"sudo tcpdump -ni {ext} '{protocol} port {int(external_port)}'",
'watch_internal': f"sudo tcpdump -ni {NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'",
'watch_internal': f"sudo tcpdump -ni {route_interface_for_ip(vm_ip) if vm_ip else NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'",
},
'raw': {
'domifaddr': domifaddr['stdout'] or domifaddr['stderr'],
+244
Просмотреть файл
@@ -0,0 +1,244 @@
"""View helpers: turn virsh/system output into plain-language data for templates."""
import os
import re
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any
VM_STATES = {
"running": ("Работает", "success"),
"idle": ("Работает", "success"),
"blocked": ("Работает", "success"),
"paused": ("Приостановлена", "warning"),
"pmsuspended": ("Спящий режим", "warning"),
"in shutdown": ("Выключается", "warning"),
"shut off": ("Выключена", "neutral"),
"shutoff": ("Выключена", "neutral"),
"crashed": ("Сбой", "danger"),
"dying": ("Останавливается", "warning"),
}
OPERATION_STATES = {
"success": ("Готово", "success"),
"error": ("Ошибка", "danger"),
"running": ("Выполняется", "info"),
"queued": ("В очереди", "neutral"),
}
SERVICE_LABELS = {
"libvirtd": "Виртуализация",
"virtlogd": "Журналы машин",
"cockpit": "Cockpit",
"dashboard": "Экран сервера",
"web": "Панель управления",
}
PORT_PRESETS = [
{"port": "22", "label": "SSH — удалённый доступ к Linux", "protocol": "tcp"},
{"port": "80", "label": "Сайт (HTTP)", "protocol": "tcp"},
{"port": "443", "label": "Сайт (HTTPS)", "protocol": "tcp"},
{"port": "3389", "label": "Удалённый рабочий стол Windows (RDP)", "protocol": "tcp"},
{"port": "25565", "label": "Minecraft", "protocol": "tcp"},
{"port": "3306", "label": "MySQL / MariaDB", "protocol": "tcp"},
{"port": "5432", "label": "PostgreSQL", "protocol": "tcp"},
{"port": "1194", "label": "OpenVPN", "protocol": "udp"},
{"port": "51820", "label": "WireGuard VPN", "protocol": "udp"},
{"port": "5060", "label": "SIP-телефония", "protocol": "udp"},
{"port": "10000-20000", "label": "Голос VoIP (RTP)", "protocol": "udp"},
]
WELL_KNOWN_PORTS = {int(item["port"]): item["label"].split(" (")[0].split(" — ")[0] for item in PORT_PRESETS if item["port"].isdigit()}
VM_PRESETS = [
{"id": "mini", "title": "Мини", "hint": "Роутер, VPN, лёгкий сервис", "vcpus": 1, "memory": 1024, "disk_size": 10},
{"id": "standard", "title": "Стандарт", "hint": "Сайт, база данных, Linux-сервер", "vcpus": 2, "memory": 4096, "disk_size": 40},
{"id": "power", "title": "Мощная", "hint": "Windows, 1С, игровой сервер", "vcpus": 4, "memory": 8192, "disk_size": 80},
]
def vm_state(state: str) -> dict[str, Any]:
key = (state or "").strip().lower()
label, tone = VM_STATES.get(key, (state or "Неизвестно", "neutral"))
return {"label": label, "tone": tone, "running": tone == "success", "raw": state}
def operation_state(status: str) -> dict[str, str]:
label, tone = OPERATION_STATES.get((status or "").lower(), (status or "—", "neutral"))
return {"label": label, "tone": tone}
def service_state(state: str) -> dict[str, str]:
if state == "active":
return {"label": "Работает", "tone": "success"}
if state in ("activating", "reloading"):
return {"label": "Запускается", "tone": "warning"}
if state == "failed":
return {"label": "Сбой", "tone": "danger"}
return {"label": "Остановлена", "tone": "neutral"}
def _kib_to_mb(value: str) -> int:
match = re.search(r"(\d+)", value or "")
if not match:
return 0
amount = int(match.group(1))
low = (value or "").lower()
if "mib" in low:
return amount
if "gib" in low:
return amount * 1024
return amount // 1024
def parse_dominfo(text: str) -> dict[str, Any]:
fields: dict[str, str] = {}
for line in (text or "").splitlines():
if ":" in line:
key, value = line.split(":", 1)
fields[key.strip().lower()] = value.strip()
try:
vcpus = int(fields.get("cpu(s)", "0") or 0)
except ValueError:
vcpus = 0
autostart = fields.get("autostart", "").lower()
return {
"state": fields.get("state", ""),
"vcpus": vcpus,
"memory_mb": _kib_to_mb(fields.get("used memory", "")) or _kib_to_mb(fields.get("max memory", "")),
"max_memory_mb": _kib_to_mb(fields.get("max memory", "")),
"autostart": autostart in ("enable", "enabled", "yes", "on"),
"autostart_known": bool(autostart),
"persistent": fields.get("persistent", "") == "yes",
"os_type": fields.get("os type", ""),
"uuid": fields.get("uuid", ""),
}
def _table_rows(text: str) -> list[list[str]]:
rows = []
for line in (text or "").splitlines():
stripped = line.strip()
if not stripped or set(stripped) <= {"-", " "}:
continue
rows.append(stripped.split())
return rows[1:] if rows else []
def parse_domblklist(text: str) -> list[dict[str, str]]:
disks = []
for parts in _table_rows(text):
if len(parts) >= 4:
kind, device, target, source = parts[0], parts[1], parts[2], " ".join(parts[3:])
elif len(parts) >= 2:
kind, device, target, source = "file", "disk", parts[0], " ".join(parts[1:])
else:
continue
if source == "-":
source = ""
disks.append({
"type": kind,
"device": device,
"target": target,
"source": source,
"name": Path(source).name if source else "Пусто",
"is_cdrom": device == "cdrom",
"label": "Привод CD/DVD" if device == "cdrom" else "Диск",
})
return disks
def parse_domiflist(text: str) -> list[dict[str, str]]:
interfaces = []
for parts in _table_rows(text):
if len(parts) < 2:
continue
padded = parts + ["—"] * (5 - len(parts))
interfaces.append({"interface": padded[0], "type": padded[1], "source": padded[2], "model": padded[3], "mac": padded[4]})
return interfaces
def format_mb(value: int | float) -> str:
value = float(value or 0)
if value >= 1024:
gb = value / 1024
return f"{gb:.0f} ГБ" if gb >= 10 or gb.is_integer() else f"{gb:.1f} ГБ"
return f"{value:.0f} МБ"
def format_gb(value: float) -> str:
if value >= 1024:
return f"{value / 1024:.1f} ТБ"
return f"{value:.0f} ГБ" if value >= 10 else f"{value:.1f} ГБ"
def _meminfo() -> dict[str, int]:
data = {}
try:
for line in Path("/proc/meminfo").read_text().splitlines():
key, value = line.split(":", 1)
data[key] = int(value.split()[0])
except (OSError, ValueError):
pass
return data
def host_stats(storage_path: Path) -> dict[str, Any]:
cpu_count = os.cpu_count() or 1
try:
load1 = float(Path("/proc/loadavg").read_text().split()[0])
except (OSError, ValueError, IndexError):
load1 = 0.0
mem = _meminfo()
mem_total = mem.get("MemTotal", 0) / 1024 / 1024
mem_used = max(0.0, mem_total - mem.get("MemAvailable", 0) / 1024 / 1024)
path = storage_path if storage_path.exists() else Path("/")
try:
usage = shutil.disk_usage(path)
disk_total, disk_used, disk_free = usage.total / 1024 ** 3, usage.used / 1024 ** 3, usage.free / 1024 ** 3
except OSError:
disk_total = disk_used = disk_free = 0.0
def pct(used: float, total: float) -> int:
return int(round(min(100.0, max(0.0, used / total * 100)))) if total else 0
return {
"cpu_count": cpu_count,
"cpu_pct": pct(load1, cpu_count),
"load1": load1,
"mem_total": format_gb(mem_total),
"mem_used": format_gb(mem_used),
"mem_pct": pct(mem_used, mem_total),
"disk_total": format_gb(disk_total),
"disk_used": format_gb(disk_used),
"disk_free": format_gb(disk_free),
"disk_pct": pct(disk_used, disk_total),
}
def level_tone(pct: int) -> str:
return "danger" if pct >= 90 else "warning" if pct >= 75 else "success"
def greeting(now: datetime | None = None) -> str:
hour = (now or datetime.now()).hour
if 5 <= hour < 12:
return "Доброе утро"
if 12 <= hour < 18:
return "Добрый день"
if 18 <= hour < 23:
return "Добрый вечер"
return "Доброй ночи"
def plural(count: int, one: str, few: str, many: str) -> str:
count = abs(int(count))
if count % 10 == 1 and count % 100 != 11:
return one
if 2 <= count % 10 <= 4 and not 12 <= count % 100 <= 14:
return few
return many
def port_service(port: int) -> str:
return WELL_KNOWN_PORTS.get(int(port or 0), "")
+20
Просмотреть файл
@@ -1,5 +1,25 @@
# Virtuality web panel dependencies.
# Direct dependencies:
fastapi==0.115.6
uvicorn[standard]==0.34.0
jinja2==3.1.5
python-multipart==0.0.20
itsdangerous==2.2.0
# Transitive dependencies, pinned for reproducible installs and offline image wheels:
annotated-types==0.8.0
anyio==4.15.1
click==8.5.0
h11==0.16.0
httptools==0.8.0
idna==3.20
markupsafe==3.0.3
pydantic==2.13.5
pydantic-core==2.46.5
python-dotenv==1.2.3
pyyaml==6.0.3
starlette==0.41.3
typing-extensions==4.16.0
typing-inspection==0.4.4
uvloop==0.22.1
watchfiles==1.3.0
websockets==17.1
+27
Просмотреть файл
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Re-apply Virtuality NAT port forwards after boot.
nftables/iptables rules created by the panel live only in the kernel, so they
are lost on reboot. virtuality-network.service runs this once at startup.
"""
import sys
import network_core
def main() -> int:
if not network_core.PORT_FORWARDS_FILE.exists():
print("No port forwards configured, nothing to restore")
return 0
try:
result = network_core.apply_port_forwards()
except network_core.NetworkError as exc:
print(f"Failed to restore port forwards: {exc}", file=sys.stderr)
return 1
count = len(network_core.load_port_forwards())
print(f"Restored {count} port forward(s), nft ok={result['nft_apply']['ok']}")
return 0
if __name__ == "__main__":
sys.exit(main())
+43
Просмотреть файл
@@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+558 -269
Просмотреть файл
@@ -1,297 +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; }
}
@media (max-width: 1024px) {
.app { grid-template-columns: 1fr; background: none; }
.sidebar { position: fixed; left: 0; top: 0; z-index: 200; width: 280px; transform: translateX(-100%); transition: transform .25s ease; box-shadow: var(--shadow-lg); }
body.nav-open .sidebar { transform: none; }
body.nav-open::after { content: ""; position: fixed; inset: 0; z-index: 150; background: rgba(10,13,20,.4); }
.mobilebar { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(10px); }
.mobilebar .logo { padding: 0; }
.content { padding: 22px 16px 48px; }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.span-2 { grid-column: auto; }
.auth { grid-template-columns: 1fr; }
.auth-hero { display: none; }
.auth-mobile-brand { display: flex; margin-bottom: 28px; }
}
@media (max-width: 640px) {
h1 { font-size: 22px; }
.grid-4 { grid-template-columns: 1fr 1fr; gap: 12px; }
.form-row, .form-row-3 { grid-template-columns: 1fr; }
.stat { padding: 14px; }
.stat-value { font-size: 21px; }
.card-header, .card-body, .list-item, .vm-row, .table td, .table th { padding-left: 16px; padding-right: 16px; }
.step-body { padding: 16px; }
.page-header { align-items: flex-start; }
.page-actions { width: 100%; }
.vm-actions .btn-label { display: none; }
.hide-sm { display: none !important; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
+93
Просмотреть файл
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Двоичный файл не отображается.
Двоичные данные
Двоичный файл не отображается.
+60
Просмотреть файл
@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<!-- Lucide icons (ISC License, https://lucide.dev) -->
<symbol id="i-layout-dashboard" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1" /><rect width="7" height="5" x="14" y="3" rx="1" /><rect width="7" height="9" x="14" y="12" rx="1" /><rect width="7" height="5" x="3" y="16" rx="1" /></symbol>
<symbol id="i-server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /><rect width="20" height="8" x="2" y="14" rx="2" ry="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14" /><path d="M12 5v14" /></symbol>
<symbol id="i-disc-3" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M6 12c0-1.7.7-3.2 1.8-4.2" /><circle cx="12" cy="12" r="2" /><path d="M18 12c0 1.7-.7 3.2-1.8 4.2" /></symbol>
<symbol id="i-hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01" /><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /><path d="M21.946 12.013H2.054" /><path d="M6 16h.01" /></symbol>
<symbol id="i-network" viewBox="0 0 24 24"><rect x="16" y="16" width="6" height="6" rx="1" /><rect x="2" y="16" width="6" height="6" rx="1" /><rect x="9" y="2" width="6" height="6" rx="1" /><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" /><path d="M12 12V8" /></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" /></symbol>
<symbol id="i-list-checks" viewBox="0 0 24 24"><path d="M13 5h8" /><path d="M13 12h8" /><path d="M13 19h8" /><path d="m3 17 2 2 4-4" /><path d="m3 7 2 2 4-4" /></symbol>
<symbol id="i-scroll-text" viewBox="0 0 24 24"><path d="M15 12h-5" /><path d="M15 8h-5" /><path d="M19 17V5a2 2 0 0 0-2-2H4" /><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" /></symbol>
<symbol id="i-refresh-cw" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M8 16H3v5" /></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24"><path d="M12 20v2" /><path d="M12 2v2" /><path d="M17 20v2" /><path d="M17 2v2" /><path d="M2 12h2" /><path d="M2 17h2" /><path d="M2 7h2" /><path d="M20 12h2" /><path d="M20 17h2" /><path d="M20 7h2" /><path d="M7 20v2" /><path d="M7 2v2" /><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
<symbol id="i-memory-stick" viewBox="0 0 24 24"><path d="M12 12v-2" /><path d="M12 18v-2" /><path d="M16 12v-2" /><path d="M16 18v-2" /><path d="M2 11h1.5" /><path d="M20 18v-2" /><path d="M20.5 11H22" /><path d="M4 18v-2" /><path d="M8 12v-2" /><path d="M8 18v-2" /><rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
<symbol id="i-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5V19A9 3 0 0 0 21 19V5" /><path d="M3 12A9 3 0 0 0 21 12" /></symbol>
<symbol id="i-circle-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path d="M12 17h.01" /></symbol>
<symbol id="i-log-out" viewBox="0 0 24 24"><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" /></symbol>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol>
<symbol id="i-monitor" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2" /><line x1="8" x2="16" y1="21" y2="21" /><line x1="12" x2="12" y1="17" y2="21" /></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
<symbol id="i-square" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
<symbol id="i-power" viewBox="0 0 24 24"><path d="M12 2v10" /><path d="M18.4 6.6a9 9 0 1 1-12.77.04" /></symbol>
<symbol id="i-rotate-cw" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /></symbol>
<symbol id="i-trash-2" viewBox="0 0 24 24"><path d="M10 11v6" /><path d="M14 11v6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
<symbol id="i-ellipsis" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" /></symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" /></symbol>
<symbol id="i-cloud-upload" viewBox="0 0 24 24"><path d="M12 13v8" /><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" /><path d="m8 17 4-4 4 4" /></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></symbol>
<symbol id="i-circle-check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m16 9-5.5 5.5L8 12" /></symbol>
<symbol id="i-triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /><path d="M12 9v4" /><path d="M12 17h.01" /></symbol>
<symbol id="i-circle-x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m15 9-6 6" /><path d="m9 9 6 6" /></symbol>
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" /></symbol>
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></symbol>
<symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14" /><path d="m12 5 7 7-7 7" /></symbol>
<symbol id="i-external-link" viewBox="0 0 24 24"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></symbol>
<symbol id="i-zap" viewBox="0 0 24 24"><path d="M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z" /></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34" /><circle cx="11" cy="11" r="8" /></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 5h16" /><path d="M4 12h16" /><path d="M4 19h16" /></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3" /><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></symbol>
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" /></symbol>
<symbol id="i-package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" /><path d="M12 22V12" /><polyline points="3.29 7 12 12 20.71 7" /><path d="m7.5 4.27 9 5.15" /></symbol>
<symbol id="i-terminal" viewBox="0 0 24 24"><path d="M12 19h8" /><path d="m4 17 6-6-6-6" /></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></symbol>
<symbol id="i-gauge" viewBox="0 0 24 24"><path d="m12 14 4-4" /><path d="M3.34 19a10 10 0 1 1 17.32 0" /></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" /></symbol>
<symbol id="i-sparkles" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" /><path d="M20 2v4" /><path d="M22 4h-4" /><circle cx="4" cy="20" r="2" /></symbol>
<symbol id="i-layers" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" /><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" /><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" /></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><rect x="14" y="3" width="5" height="18" rx="1" /><rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
<symbol id="i-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
<symbol id="i-circle-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1" /><circle cx="12" cy="12" r="10" /></symbol>
<symbol id="i-plug" viewBox="0 0 24 24"><path d="M12 22v-5" /><path d="M15 8V2" /><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z" /><path d="M9 8V2" /></symbol>
</svg>

После

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

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