feat: v1.1.0 — backup module, IPAM node binding, HA, task queue, theme CSS
- Backup module: full CRUD for storages, backup files, scheduled jobs (APScheduler) - Proxmox client: storage/backup/restore/HA methods - IPAM: proxmox_node field, server+node binding for networks and allocations - Task queue service: improvements and new task types - Monitoring worker: extended metrics and alerts - HA support: add/remove VMs from Proxmox HA groups - tasks.html: new task tracking page - proxmox_vms.html: major UI update (bulk ops, HA, snapshots) - backups.html: full backup management UI (3 tabs) - base.html: sidebar backup link, websocket manager - theme.css: full CSS variables theme, 395 lines added - migrations: migration #15 (BackupJob), #16 (proxmox_node) - RBAC: backup resource permissions - websocket_manager.py: new module for WS connections
This commit is contained in:
@@ -3,7 +3,7 @@ import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect, Query
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.exception_handlers import http_exception_handler
|
||||
@@ -71,6 +71,21 @@ def setup_logging():
|
||||
|
||||
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
||||
|
||||
# Suppress noisy third-party loggers
|
||||
for noisy_logger in (
|
||||
"apscheduler",
|
||||
"apscheduler.scheduler",
|
||||
"apscheduler.executors.default",
|
||||
"httpx",
|
||||
"httpcore",
|
||||
"httpcore.http11",
|
||||
"httpcore.connection",
|
||||
"urllib3",
|
||||
"urllib3.connectionpool",
|
||||
"multipart",
|
||||
):
|
||||
logging.getLogger(noisy_logger).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -277,6 +292,87 @@ def create_app() -> FastAPI:
|
||||
"""Proxy to proxmox virtual-machines API"""
|
||||
return RedirectResponse(url="/proxmox/api/virtual-machines", status_code=307)
|
||||
|
||||
# Task Manager page
|
||||
@app.get("/tasks", include_in_schema=False)
|
||||
async def tasks_page(request: Request):
|
||||
"""Task Manager page"""
|
||||
from .i18n import t
|
||||
lang = request.cookies.get("language", "ru")
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t("nav_tasks", lang) if callable(t) else "Менеджер задач",
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("tasks.html", context)
|
||||
|
||||
# WebSocket endpoint for real-time task updates
|
||||
@app.websocket("/ws/tasks")
|
||||
async def websocket_tasks(websocket: WebSocket, token: str = Query(None)):
|
||||
"""
|
||||
WebSocket endpoint for live task manager updates.
|
||||
Auth via ?token=<jwt> query parameter (browsers cannot set WS headers).
|
||||
"""
|
||||
from .auth import decode_access_token
|
||||
from .db import SessionLocal
|
||||
from .models import TaskQueue, ProxmoxTask, User
|
||||
from .websocket_manager import ws_manager
|
||||
import json
|
||||
|
||||
# --- Authenticate --------------------------------------------------
|
||||
if not token:
|
||||
await websocket.close(code=4001, reason="Missing token")
|
||||
return
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise ValueError("No subject in token")
|
||||
except Exception:
|
||||
await websocket.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.username == username, User.is_active == True).first()
|
||||
if not user:
|
||||
await websocket.close(code=4001, reason="User not found")
|
||||
return
|
||||
user_id = user.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# --- Connect -------------------------------------------------------
|
||||
await ws_manager.connect(websocket, user_id)
|
||||
|
||||
try:
|
||||
# Send initial snapshot
|
||||
db = SessionLocal()
|
||||
try:
|
||||
bulk = db.query(TaskQueue).filter(TaskQueue.user_id == user_id).order_by(TaskQueue.created_at.desc()).limit(30).all()
|
||||
prox = db.query(ProxmoxTask).filter(ProxmoxTask.user_id == user_id).order_by(ProxmoxTask.created_at.desc()).limit(30).all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
all_tasks = sorted(
|
||||
[t.to_dict() for t in bulk] + [t.to_dict() for t in prox],
|
||||
key=lambda x: x.get("created_at") or "",
|
||||
reverse=True,
|
||||
)[:30]
|
||||
await websocket.send_text(json.dumps({"type": "task_list", "tasks": all_tasks}, default=str))
|
||||
|
||||
# Keep connection alive – wait for client messages / ping-pong
|
||||
while True:
|
||||
try:
|
||||
msg = await websocket.receive_text()
|
||||
if msg == "ping":
|
||||
await websocket.send_text(json.dumps({"type": "pong"}))
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
finally:
|
||||
ws_manager.disconnect(websocket, user_id)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth_router.router, tags=["auth"])
|
||||
app.include_router(dashboard_router.router, tags=["dashboard"])
|
||||
|
||||
Reference in New Issue
Block a user