Старт панели переведён с устаревшего on_event на lifespan, шаблоны вызываются как TemplateResponse(request, name): ушла сотня предупреждений FastAPI
Этот коммит содержится в:
+41
-34
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlsplit
|
||||
import xml.etree.ElementTree as ET
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
@@ -45,7 +46,22 @@ OPERATIONS_DIR = Path("/var/log/virtuality/operations")
|
||||
DEFAULT_BRIDGE = "br0"
|
||||
NOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)
|
||||
|
||||
app = FastAPI(title="Virtuality Panel")
|
||||
def run_startup_tasks() -> None:
|
||||
"""После перезапуска панели: закрыть задачи, оборванные перезапуском, и убрать недокачанные загрузки."""
|
||||
import core
|
||||
from features import downloads
|
||||
|
||||
core.interrupt_orphaned_operations()
|
||||
downloads.close_stale_downloads()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
run_startup_tasks()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Virtuality Panel", lifespan=lifespan)
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
static_dir = BASE_DIR / "static"
|
||||
static_dir.mkdir(exist_ok=True)
|
||||
@@ -1260,7 +1276,7 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
|
||||
def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200):
|
||||
profile = host_profile.load_host_profile()
|
||||
default_mode = profile.get("recommended_network", "nat")
|
||||
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
|
||||
return templates.TemplateResponse(request, "vm_create.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
|
||||
|
||||
|
||||
# Virtuality noVNC console patch
|
||||
@@ -1296,20 +1312,20 @@ async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWri
|
||||
def login_page(request: Request):
|
||||
if get_current_user(request):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": None, "configured": is_configured(), "auth_user": AUTH_USER})
|
||||
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": None, "configured": is_configured(), "auth_user": AUTH_USER})
|
||||
|
||||
|
||||
@app.post("/login", response_class=HTMLResponse)
|
||||
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
|
||||
if not is_configured():
|
||||
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Панель ещё не настроена. Запусти установщик веб-панели повторно.", "configured": False, "auth_user": AUTH_USER}, status_code=500)
|
||||
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": "Панель ещё не настроена. Запусти установщик веб-панели повторно.", "configured": False, "auth_user": AUTH_USER}, status_code=500)
|
||||
key = client_key(request)
|
||||
retry_after = login_throttle.retry_after(key)
|
||||
if retry_after:
|
||||
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Повтори через {retry_after} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429, headers={"Retry-After": str(retry_after)})
|
||||
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Повтори через {retry_after} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429, headers={"Retry-After": str(retry_after)})
|
||||
if not verify_linux_password(username, password):
|
||||
login_throttle.record_failure(key)
|
||||
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401)
|
||||
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401)
|
||||
login_throttle.record_success(key)
|
||||
token = serializer.dumps({"user": AUTH_USER})
|
||||
response = RedirectResponse(url="/", status_code=303)
|
||||
@@ -1333,7 +1349,7 @@ def dashboard(request: Request):
|
||||
return RedirectResponse(url="/setup", status_code=303)
|
||||
services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "web": service_state("virtuality-web.service")}
|
||||
service_rows = [{"key": key, "name": presenters.SERVICE_LABELS.get(key, key), "state": state, **presenters.service_state(state)} for key, state in services.items()]
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()})
|
||||
return templates.TemplateResponse(request, "dashboard.html", {"app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()})
|
||||
|
||||
|
||||
@app.get("/help", response_class=HTMLResponse)
|
||||
@@ -1341,7 +1357,7 @@ def help_page(request: Request):
|
||||
auth_redirect = require_auth(request)
|
||||
if auth_redirect:
|
||||
return auth_redirect
|
||||
return templates.TemplateResponse("help.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "host_ip": system_summary()["ip"]})
|
||||
return templates.TemplateResponse(request, "help.html", {"app_name": APP_NAME, "user": AUTH_USER, "host_ip": system_summary()["ip"]})
|
||||
|
||||
|
||||
@app.get("/host", response_class=HTMLResponse)
|
||||
@@ -1350,7 +1366,7 @@ def host_page(request: Request):
|
||||
if auth_redirect:
|
||||
return auth_redirect
|
||||
profile = host_profile.load_host_profile()
|
||||
return templates.TemplateResponse("host.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "profile": profile, "profile_json": json.dumps(profile, ensure_ascii=False, indent=2)})
|
||||
return templates.TemplateResponse(request, "host.html", {"app_name": APP_NAME, "user": AUTH_USER, "profile": profile, "profile_json": json.dumps(profile, ensure_ascii=False, indent=2)})
|
||||
|
||||
|
||||
@app.post("/host/refresh")
|
||||
@@ -1368,7 +1384,7 @@ def iso_page(request: Request, error: str | None = None):
|
||||
auth_redirect = require_auth(request)
|
||||
if auth_redirect:
|
||||
return auth_redirect
|
||||
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error})
|
||||
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error})
|
||||
|
||||
|
||||
@app.post("/iso/upload", response_class=HTMLResponse)
|
||||
@@ -1378,11 +1394,11 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)):
|
||||
return auth_redirect
|
||||
safe_name = safe_iso_filename(iso_file.filename or "")
|
||||
if not safe_name:
|
||||
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400)
|
||||
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400)
|
||||
ISO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
target = ISO_DIR / safe_name
|
||||
if target.exists():
|
||||
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400)
|
||||
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400)
|
||||
tmp_target = ISO_DIR / f".{safe_name}.uploading"
|
||||
try:
|
||||
with tmp_target.open("wb") as out:
|
||||
@@ -1391,7 +1407,7 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)):
|
||||
refresh_iso_pool()
|
||||
except Exception as exc:
|
||||
tmp_target.unlink(missing_ok=True)
|
||||
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)
|
||||
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)
|
||||
return RedirectResponse(url="/iso", status_code=303)
|
||||
|
||||
|
||||
@@ -1424,7 +1440,7 @@ 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})
|
||||
return templates.TemplateResponse(request, "disk_images.html", {"app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error})
|
||||
|
||||
|
||||
@app.post("/disk-images/upload", response_class=HTMLResponse)
|
||||
@@ -1434,7 +1450,7 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
||||
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)
|
||||
return templates.TemplateResponse(request, "disk_images.html", {"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] = []
|
||||
@@ -1455,7 +1471,7 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
||||
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)
|
||||
return templates.TemplateResponse(request, "disk_images.html", {"app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500)
|
||||
|
||||
operations = []
|
||||
for path in saved_paths:
|
||||
@@ -1507,8 +1523,7 @@ def safe_network_template(request: Request, error: str | None = None, status_cod
|
||||
"nft_rules": "",
|
||||
}
|
||||
error = error or f"Ошибка чтения сетевого состояния: {exc}"
|
||||
return templates.TemplateResponse("network.html", {
|
||||
"request": request,
|
||||
return templates.TemplateResponse(request, "network.html", {
|
||||
"app_name": APP_NAME,
|
||||
"user": AUTH_USER,
|
||||
"vms": parse_virsh_list(),
|
||||
@@ -1535,7 +1550,7 @@ def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: st
|
||||
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)
|
||||
return templates.TemplateResponse(request, "network.html", {"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:
|
||||
@@ -1616,8 +1631,7 @@ def network_diagnose(request: Request, vm_name: str = Form(...), external_port:
|
||||
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,
|
||||
return templates.TemplateResponse(request, "network.html", {
|
||||
"app_name": APP_NAME,
|
||||
"user": AUTH_USER,
|
||||
"vms": parse_virsh_list(),
|
||||
@@ -1657,7 +1671,7 @@ def update_page(request: Request):
|
||||
}
|
||||
current = str(info.get("current_version", ""))
|
||||
current_release = next((item for item in update_core.load_manifest().get("versions", []) if str(item.get("version")) == current), None)
|
||||
return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error, "current_release": current_release})
|
||||
return templates.TemplateResponse(request, "update.html", {"app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error, "current_release": current_release})
|
||||
|
||||
|
||||
@app.get("/update/status")
|
||||
@@ -1702,7 +1716,7 @@ def operations_page(request: Request):
|
||||
auth_redirect = require_auth(request)
|
||||
if auth_redirect:
|
||||
return auth_redirect
|
||||
return templates.TemplateResponse("operations.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operations": list_operations(50), "operation_css": operation_css})
|
||||
return templates.TemplateResponse(request, "operations.html", {"app_name": APP_NAME, "user": AUTH_USER, "operations": list_operations(50), "operation_css": operation_css})
|
||||
|
||||
|
||||
@app.get("/operations/{operation_id}", response_class=HTMLResponse)
|
||||
@@ -1713,7 +1727,7 @@ def operation_detail_page(request: Request, operation_id: str):
|
||||
operation = read_operation(operation_id)
|
||||
if not operation:
|
||||
return RedirectResponse(url="/operations", status_code=303)
|
||||
return templates.TemplateResponse("operation_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css})
|
||||
return templates.TemplateResponse(request, "operation_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css})
|
||||
|
||||
|
||||
@app.get("/api/operations")
|
||||
@@ -1739,8 +1753,7 @@ def logs_page(request: Request, source: str = "web", lines: int = 220):
|
||||
if auth_redirect:
|
||||
return auth_redirect
|
||||
selected = read_log_source(source, lines)
|
||||
return templates.TemplateResponse("logs.html", {
|
||||
"request": request,
|
||||
return templates.TemplateResponse(request, "logs.html", {
|
||||
"app_name": APP_NAME,
|
||||
"user": AUTH_USER,
|
||||
"sources": LOG_SOURCES,
|
||||
@@ -1905,7 +1918,7 @@ def vm_console_page(request: Request, name: str):
|
||||
return auth_redirect
|
||||
if not valid_vm_name(name) or not vm_exists(name):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
return templates.TemplateResponse("console.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})
|
||||
return templates.TemplateResponse(request, "console.html", {"app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})
|
||||
|
||||
|
||||
@app.websocket("/console/ws/{token}")
|
||||
@@ -1955,7 +1968,7 @@ def vm_detail_page(request: Request, name: str):
|
||||
return auth_redirect
|
||||
if not valid_vm_name(name) or not vm_exists(name):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
return templates.TemplateResponse("vm_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "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", ""), "resource_settings": vm_resource_settings(name), "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", "")})
|
||||
return templates.TemplateResponse(request, "vm_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "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", ""), "resource_settings": vm_resource_settings(name), "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", "")})
|
||||
|
||||
|
||||
@app.post("/vm/{name}/resources")
|
||||
@@ -2085,12 +2098,6 @@ def api_health(request: Request):
|
||||
# Feature modules register template globals into app.templates, so they are imported once it exists.
|
||||
from features import backup_schedule, backups, downloads, settings, setup, snapshots, vmops # noqa: E402
|
||||
|
||||
@app.on_event("startup")
|
||||
def close_orphaned_operations() -> None:
|
||||
import core
|
||||
|
||||
core.interrupt_orphaned_operations()
|
||||
|
||||
|
||||
app.include_router(setup.router)
|
||||
app.include_router(settings.router)
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ def render(request: Request, template: str, context: dict[str, Any] | None = Non
|
||||
data = {"request": request, "app_name": APP_NAME, "user": AUTH_USER}
|
||||
data.update(context or {})
|
||||
data.update(extra)
|
||||
return templates.TemplateResponse(template, data, status_code=status_code)
|
||||
return templates.TemplateResponse(request, template, data, status_code=status_code)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- auth
|
||||
|
||||
@@ -783,9 +783,9 @@ def api_cancel_operation(request: Request, operation_id: str):
|
||||
return {"ok": True, "operation": read_operation(operation_id) or operation}
|
||||
|
||||
|
||||
@router.on_event("startup")
|
||||
def close_stale_downloads() -> None:
|
||||
"""После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part."""
|
||||
"""После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part.
|
||||
Вызывается из app.run_startup_tasks() при старте панели."""
|
||||
reap_stale_downloads()
|
||||
|
||||
|
||||
|
||||
Ссылка в новой задаче
Block a user