diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..7550ac7 --- /dev/null +++ b/backend/.env @@ -0,0 +1,5 @@ +PVE_HOST=https://192.168.31.4:8006 +PVE_NODE=pve1 +PVE_TOKEN_NAME=root@pam!panel +PVE_TOKEN_VALUE=0b20b5cd-59ec-4abc-88ff-3e2a5710964a +SECRET_KEY=dfdbda46db408e2a6c1a5ff1e83f79e0a65e0b02a96586e7c7b45b6a3f3fe41f diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..4821dba --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,17 @@ +# База данных (для продакшена лучше Postgres, см. docker-compose.yml) +DATABASE_URL=postgresql://panel:panel@db:5432/panel + +# Секрет для подписи JWT — обязательно смените! +SECRET_KEY=замени-меня-на-случайную-строку + +# Подключение к Proxmox +PVE_HOST=https://192.168.1.10:8006 +PVE_NODE=pve +# Токен создаётся в Proxmox: Datacenter -> Permissions -> API Tokens +# Формат имени: user@realm!tokenid, например root@pam!panel +PVE_TOKEN_NAME=root@pam!panel +PVE_TOKEN_VALUE=00000000-0000-0000-0000-000000000000 +PVE_VERIFY_SSL=false + +VMID_RANGE_START=9000 +VMID_RANGE_END=9999 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f1dc70a --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..00c3738 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,25 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Общие + database_url: str = "sqlite:///./panel.db" + secret_key: str = "change-me-please-super-secret" + access_token_expire_minutes: int = 60 * 12 + + # Proxmox + pve_host: str = "https://proxmox.local:8006" + pve_token_name: str = "root@pam!panel" + pve_token_value: str = "" + pve_verify_ssl: bool = False + pve_node: str = "pve" + + # Пул VMID, из которого будут выделяться номера новым машинам + vmid_range_start: int = 9000 + vmid_range_end: int = 9999 + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..308d46d --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,17 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +from .config import settings + +connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +engine = create_engine(settings.database_url, connect_args=connect_args) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..21f1472 --- /dev/null +++ b/backend/app/deps.py @@ -0,0 +1,35 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session + +from . import models +from .database import get_db +from .security import decode_access_token + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + + +def get_current_user( + token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) +) -> models.User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Не удалось подтвердить учётные данные", + headers={"WWW-Authenticate": "Bearer"}, + ) + payload = decode_access_token(token) + if payload is None: + raise credentials_exception + user_id = payload.get("sub") + if user_id is None: + raise credentials_exception + user = db.query(models.User).filter(models.User.id == int(user_id)).first() + if user is None or not user.is_active: + raise credentials_exception + return user + + +def require_admin(user: models.User = Depends(get_current_user)) -> models.User: + if user.role != models.Role.admin: + raise HTTPException(status_code=403, detail="Требуются права администратора") + return user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..24bbb14 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from . import models +from .database import engine +from .routers import auth, templates, instances, admin, console + +models.Base.metadata.create_all(bind=engine) + +app = FastAPI(title="Proxmox VPS Panel", version="0.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # в проде укажите конкретный домен фронтенда + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(templates.router) +app.include_router(instances.router) +app.include_router(admin.router) +app.include_router(console.router) + + +@app.get("/health") +def health(): + return {"status": "ok"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..b71d67e --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,81 @@ +import enum +from datetime import datetime + +from sqlalchemy import ( + Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, +) +from sqlalchemy.orm import relationship + +from .database import Base + + +class Role(str, enum.Enum): + admin = "admin" + client = "client" + + +class GuestType(str, enum.Enum): + vm = "vm" + lxc = "lxc" + + +class InstanceStatus(str, enum.Enum): + creating = "creating" + running = "running" + stopped = "stopped" + error = "error" + deleting = "deleting" + deleted = "deleted" + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + password_hash = Column(String, nullable=False) + role = Column(Enum(Role), default=Role.client, nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + + instances = relationship("Instance", back_populates="owner") + + +class Template(Base): + """Шаблон VPS: связывает тарифный план с исходным Proxmox-шаблоном (VM template / CT template).""" + __tablename__ = "templates" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + description = Column(String, default="") + guest_type = Column(Enum(GuestType), nullable=False) + # VMID шаблона в Proxmox, с которого делаем клон (для VM) или CT template volid (для LXC, напр. local:vztmpl/....tar.zst) + source_vmid = Column(Integer, nullable=True) # для VM-шаблонов (клонирование) + source_template = Column(String, nullable=True) # для LXC (путь к .tar.zst шаблону) + cores = Column(Integer, default=1) + memory_mb = Column(Integer, default=1024) + disk_gb = Column(Integer, default=10) + is_active = Column(Boolean, default=True) + + instances = relationship("Instance", back_populates="template") + + +class Instance(Base): + __tablename__ = "instances" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + vmid = Column(Integer, unique=True, index=True, nullable=False) + node = Column(String, nullable=False) + guest_type = Column(Enum(GuestType), nullable=False) + status = Column(Enum(InstanceStatus), default=InstanceStatus.creating) + root_password = Column(String, nullable=True) + ciuser = Column(String, nullable=True) # хранится только для LXC при создании, чтобы показать один раз + + owner_id = Column(Integer, ForeignKey("users.id")) + template_id = Column(Integer, ForeignKey("templates.id")) + + created_at = Column(DateTime, default=datetime.utcnow) + + owner = relationship("User", back_populates="instances") + template = relationship("Template", back_populates="instances") diff --git a/backend/app/proxmox_client.py b/backend/app/proxmox_client.py new file mode 100644 index 0000000..8b4fed6 --- /dev/null +++ b/backend/app/proxmox_client.py @@ -0,0 +1,319 @@ +import random +import string +import time + +from proxmoxer import ProxmoxAPI + +from .config import settings + + +def _client() -> ProxmoxAPI: + """Создаёт клиент Proxmox API, аутентифицированный по API-токену.""" + host = settings.pve_host.replace("https://", "").replace("http://", "").split(":")[0] + return ProxmoxAPI( + host, + user=settings.pve_token_name.split("!")[0], + token_name=settings.pve_token_name.split("!")[1], + token_value=settings.pve_token_value, + verify_ssl=settings.pve_verify_ssl, + ) + + +def gen_password(length: int = 14) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(random.choice(alphabet) for _ in range(length)) + + +def get_next_vmid() -> int: + """Берёт свободный VMID у самого Proxmox (гарантированно не занят).""" + px = _client() + return int(px.cluster.nextid.get()) + + +def clone_vm(source_vmid: int, new_vmid: int, name: str, node: str = None, storage: str = None) -> None: + node = node or settings.pve_node + px = _client() + upid = px.nodes(node).qemu(source_vmid).clone.post( + newid=new_vmid, + name=name, + full=1, + ) + _wait_task(px, node, upid) + + +def resize_vm(vmid: int, cores: int, memory_mb: int, node: str = None) -> None: + node = node or settings.pve_node + px = _client() + px.nodes(node).qemu(vmid).config.put(cores=cores, memory=memory_mb) + + +def create_lxc( + new_vmid: int, + name: str, + template_volid: str, + cores: int, + memory_mb: int, + disk_gb: int, + storage: str = "local-lvm", + node: str = None, +) -> str: + """Создаёт LXC-контейнер из шаблона. Возвращает сгенерированный root-пароль.""" + node = node or settings.pve_node + px = _client() + password = gen_password() + upid = px.nodes(node).lxc.post( + vmid=new_vmid, + hostname=name, + ostemplate=template_volid, + cores=cores, + memory=memory_mb, + swap=memory_mb, + rootfs=f"{storage}:{disk_gb}", + password=password, + net0="name=eth0,bridge=vmbr0,ip=dhcp", + unprivileged=1, + ) + _wait_task(px, node, upid) + return password + + +def _wait_task(px: ProxmoxAPI, node: str, upid: str, timeout: int = 1200) -> None: + """Ждёт завершения асинхронной задачи Proxmox (клонирование, создание и т.п.).""" + start = time.time() + while time.time() - start < timeout: + status = px.nodes(node).tasks(upid).status.get() + if status.get("status") == "stopped": + if status.get("exitstatus") != "OK": + raise RuntimeError(f"Задача Proxmox завершилась с ошибкой: {status}") + return + time.sleep(2) + raise TimeoutError("Превышено время ожидания задачи Proxmox") + + +def list_vm_templates(node=None): + node = node or settings.pve_node + px = _client() + result = [] + for vm in px.nodes(node).qemu.get(): + if vm.get("template") == 1: + cfg = px.nodes(node).qemu(vm["vmid"]).config.get() + disk = 10 + for k in ("scsi0","virtio0","ide0","sata0"): + v = cfg.get(k,"") + if ",size=" in v: + import re as _re + m = _re.search(r"size=(\d+)G", v) + if m: disk = int(m.group(1)); break + result.append({ + "vmid": vm["vmid"], "name": vm.get("name",""), + "cores": int(cfg.get("cores",1)), "memory_mb": int(cfg.get("memory",1024)), + "disk_gb": disk, + }) + return result + +def list_lxc_templates(node=None): + node = node or settings.pve_node + px = _client() + result = [] + for st in px.nodes(node).storage.get(): + if "vztmpl" in st.get("content",""): + try: + for item in px.nodes(node).storage(st["storage"]).content.get(): + if item.get("content") == "vztmpl": + name = item["volid"].split("/")[-1] + for ext in (".tar.zst",".tar.gz",".tar.xz"): name = name.replace(ext,"") + result.append({"volid": item["volid"], "name": name, "storage": st["storage"], "size_mb": round(item.get("size",0)/(1024**2),1)}) + except: pass + return result + +def resize_disk(vmid: int, disk_gb: int, node: str = None) -> None: + node = node or settings.pve_node + px = _client() + config = px.nodes(node).qemu(vmid).config.get() + for key in ("scsi0","virtio0","ide0","sata0"): + val = config.get(key,"") + if val: + import re as _re + m = _re.search(r"size=(\d+)G", val) + current = int(m.group(1)) if m else 0 + if disk_gb > current: + px.nodes(node).qemu(vmid).resize.put(disk=key, size=f"+{disk_gb - current}G") + break + +def configure_cloud_init(vmid: int, ciuser: str, cipassword: str, node: str = None) -> None: + node = node or settings.pve_node + _client().nodes(node).qemu(vmid).config.put(ciuser=ciuser, cipassword=cipassword, ipconfig0="ip=dhcp") + +def get_instance_ip(vmid: int, node: str = None) -> str: + """Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации.""" + node = node or settings.pve_node + px = _client() + # Пробуем через гостевой агент (QEMU Guest Agent) + try: + ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", []) + for iface in ifaces: + if iface.get("name") != "lo" and iface.get("ip-addresses"): + for addr in iface["ip-addresses"]: + if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."): + return addr["ip-address"] + except Exception: + pass + # Пробуем через LXC (если контейнер) + try: + config = px.nodes(node).lxc(vmid).config.get() + # Просто пробуем разные пути для LXC + except Exception: + pass + # Пробуем через DHCP-лиз Proxmox + try: + config = px.nodes(node).qemu(vmid).config.get() + net = config.get("net0", "") + if "dhcp" in net.lower(): + return "dhcp (агент не установлен)" + except Exception: + pass + return "неизвестен" + + +def get_instance_ip(vmid: int, node: str = None) -> str: + """Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации.""" + node = node or settings.pve_node + px = _client() + # Пробуем через гостевой агент (QEMU Guest Agent) + try: + ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", []) + for iface in ifaces: + if iface.get("name") != "lo" and iface.get("ip-addresses"): + for addr in iface["ip-addresses"]: + if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."): + return addr["ip-address"] + except Exception: + pass + # Пробуем через LXC (если контейнер) + try: + config = px.nodes(node).lxc(vmid).config.get() + # Просто пробуем разные пути для LXC + except Exception: + pass + # Пробуем через DHCP-лиз Proxmox + try: + config = px.nodes(node).qemu(vmid).config.get() + net = config.get("net0", "") + if "dhcp" in net.lower(): + return "dhcp (агент не установлен)" + except Exception: + pass + return "неизвестен" + + +def get_instance_ip(vmid: int, node: str = None) -> str: + """Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации.""" + node = node or settings.pve_node + px = _client() + # Пробуем через гостевой агент (QEMU Guest Agent) + try: + ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", []) + for iface in ifaces: + if iface.get("name") != "lo" and iface.get("ip-addresses"): + for addr in iface["ip-addresses"]: + if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."): + return addr["ip-address"] + except Exception: + pass + # Пробуем через LXC (если контейнер) + try: + config = px.nodes(node).lxc(vmid).config.get() + # Просто пробуем разные пути для LXC + except Exception: + pass + # Пробуем через DHCP-лиз Proxmox + try: + config = px.nodes(node).qemu(vmid).config.get() + net = config.get("net0", "") + if "dhcp" in net.lower(): + return "dhcp (агент не установлен)" + except Exception: + pass + return "неизвестен" + + +def get_live_stats(vmid: int, node: str = None) -> dict: + """Возвращает live-показатели: cpu%, ram_used, ram_total, uptime.""" + node = node or settings.pve_node + px = _client() + try: + status = px.nodes(node).qemu(vmid).status.current.get() + return { + "cpu": round(status.get("cpu", 0) * 100, 1), + "mem_used": status.get("mem", 0), + "mem_total": status.get("maxmem", 0), + "uptime": status.get("uptime", 0), + "status": status.get("status", "unknown"), + } + except Exception: + try: + status = px.nodes(node).lxc(vmid).status.current.get() + return { + "cpu": round(status.get("cpu", 0) * 100, 1), + "mem_used": status.get("mem", 0), + "mem_total": status.get("maxmem", 0), + "uptime": status.get("uptime", 0), + "status": status.get("status", "unknown"), + } + except Exception: + return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "unknown"} + + +def get_live_stats_lxc(vmid: int, node: str = None) -> dict: + """Live-показатели LXC контейнера.""" + return get_live_stats(vmid, node) + + +def guest_action(guest_type: str, vmid: int, action: str, node: str = None) -> None: + """action: start | stop | shutdown | reboot""" + node = node or settings.pve_node + px = _client() + endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid) + if action == "start": + endpoint.status.start.post() + elif action == "stop": + endpoint.status.stop.post() + elif action == "shutdown": + endpoint.status.shutdown.post() + elif action == "reboot": + endpoint.status.reboot.post() + else: + raise ValueError(f"Неизвестное действие: {action}") + + +def delete_guest(guest_type: str, vmid: int, node: str = None) -> None: + node = node or settings.pve_node + px = _client() + if guest_type == "vm": + px.nodes(node).qemu(vmid).delete() + else: + px.nodes(node).lxc(vmid).delete() + + +def get_status(guest_type: str, vmid: int, node: str = None) -> dict: + node = node or settings.pve_node + px = _client() + if guest_type == "vm": + return px.nodes(node).qemu(vmid).status.current.get() + return px.nodes(node).lxc(vmid).status.current.get() + + +def get_vnc_ticket(guest_type: str, vmid: int, node: str = None) -> dict: + """Запрашивает у Proxmox тикет для VNC/websocket-консоли.""" + node = node or settings.pve_node + px = _client() + endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid) + result = endpoint.vncproxy.post(websocket=1) + return { + "ticket": result["ticket"], + "port": result["port"], + "node": node, + "vmid": vmid, + "guest_type": guest_type, + "pve_host": settings.pve_host, + } diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..25b51e5 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,25 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from .. import models, schemas +from ..database import get_db +from ..deps import require_admin + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/users", response_model=List[schemas.UserOut]) +def list_users(db: Session = Depends(get_db), _=Depends(require_admin)): + return db.query(models.User).order_by(models.User.created_at.desc()).all() + + +@router.post("/users/{user_id}/toggle-active") +def toggle_active(user_id: int, db: Session = Depends(get_db), _=Depends(require_admin)): + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="Пользователь не найден") + user.is_active = not user.is_active + db.commit() + return {"ok": True, "is_active": user.is_active} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..6d25437 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from .. import models, schemas +from ..database import get_db +from ..security import hash_password, verify_password, create_access_token +from ..deps import get_current_user + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=schemas.UserOut) +def register(payload: schemas.UserCreate, db: Session = Depends(get_db)): + existing = db.query(models.User).filter(models.User.email == payload.email).first() + if existing: + raise HTTPException(status_code=400, detail="Пользователь с таким email уже существует") + + # первый зарегистрированный пользователь становится администратором + is_first_user = db.query(models.User).count() == 0 + user = models.User( + email=payload.email, + password_hash=hash_password(payload.password), + role=models.Role.admin if is_first_user else models.Role.client, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.post("/login", response_model=schemas.Token) +def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.email == form_data.username).first() + if not user or not verify_password(form_data.password, user.password_hash): + raise HTTPException(status_code=401, detail="Неверный email или пароль") + token = create_access_token({"sub": str(user.id)}) + return {"access_token": token, "token_type": "bearer"} + + +@router.get("/me", response_model=schemas.UserOut) +def me(current_user: models.User = Depends(get_current_user)): + return current_user diff --git a/backend/app/routers/console.py b/backend/app/routers/console.py new file mode 100644 index 0000000..297e3fc --- /dev/null +++ b/backend/app/routers/console.py @@ -0,0 +1,69 @@ +import asyncio + +import websockets +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query + +from ..config import settings + +router = APIRouter(prefix="/console", tags=["console"]) + + +@router.websocket("/ws") +async def console_ws( + websocket: WebSocket, + node: str = Query(...), + vmid: int = Query(...), + guest_type: str = Query(...), + port: int = Query(...), + ticket: str = Query(...), +): + """ + Проксирует бинарный VNC-поток между браузером клиента и websocket-эндпоинтом + Proxmox (nodes/{node}/(qemu|lxc)/{vmid}/vncwebsocket). Тикет и порт берутся + из ответа /instances/{id}/console (см. instances.py -> pve.get_vnc_ticket). + + Примечание: в зависимости от версии Proxmox и способа аутентификации + (API-токен vs cookie-тикет) может понадобиться донастройка заголовков — + см. README, раздел "Консоль VNC". + """ + await websocket.accept() + + guest_path = "qemu" if guest_type == "vm" else "lxc" + pve_host = settings.pve_host.replace("http://", "").replace("https://", "") + upstream_url = ( + f"wss://{pve_host}/api2/json/nodes/{node}/{guest_path}/{vmid}/vncwebsocket" + f"?port={port}&vncticket={ticket}" + ) + + headers = { + "Authorization": f"PVEAPIToken={settings.pve_token_name}={settings.pve_token_value}" + } + + try: + async with websockets.connect( + upstream_url, + extra_headers=headers, + subprotocols=["binary"], + ssl=None if settings.pve_verify_ssl else False, + ) as upstream: + + async def client_to_upstream(): + try: + while True: + data = await websocket.receive_bytes() + await upstream.send(data) + except WebSocketDisconnect: + pass + + async def upstream_to_client(): + try: + async for message in upstream: + if isinstance(message, str): + message = message.encode() + await websocket.send_bytes(message) + except websockets.ConnectionClosed: + pass + + await asyncio.gather(client_to_upstream(), upstream_to_client()) + except Exception as exc: # noqa: BLE001 + await websocket.close(code=1011, reason=str(exc)[:120]) diff --git a/backend/app/routers/instances.py b/backend/app/routers/instances.py new file mode 100644 index 0000000..c09a3bb --- /dev/null +++ b/backend/app/routers/instances.py @@ -0,0 +1,231 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.orm import Session, joinedload +from .. import models, schemas, proxmox_client as pve +from ..config import settings +from ..database import get_db +from ..deps import get_current_user + +router = APIRouter(prefix="/instances", tags=["instances"]) + + +@router.get("", response_model=List[schemas.InstanceOut]) +def list_my_instances(db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + query = db.query(models.Instance).options(joinedload(models.Instance.template)) + query = query.filter(models.Instance.status != models.InstanceStatus.deleted) + if user.role != models.Role.admin: + query = query.filter(models.Instance.owner_id == user.id) + return query.order_by(models.Instance.created_at.desc()).all() + + +def _provision(instance_id: int, template_id: int, node: str): + """Фоновое создание VPS через Proxmox API.""" + from ..database import SessionLocal + db = SessionLocal() + try: + instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() + if not instance: + return + template = db.query(models.Template).filter(models.Template.id == template_id).first() + if not template: + instance.status = models.InstanceStatus.error + instance.root_password = "ERROR: шаблон не найден" + db.commit() + return + try: + if template.guest_type == models.GuestType.vm: + clean_name = ''.join(c for c in instance.name.strip() if c.isalnum() or c == '-').lower() or 'vm' + pve.clone_vm(template.source_vmid, instance.vmid, clean_name, node) + pve.resize_vm(instance.vmid, template.cores, template.memory_mb, node) + pve.resize_disk(instance.vmid, template.disk_gb, node) + if instance.ciuser and instance.root_password: + pve.configure_cloud_init(instance.vmid, instance.ciuser, instance.root_password, node) + pve.guest_action("vm", instance.vmid, "start", node) + else: + clean_name = ''.join(c for c in instance.name.strip() if c.isalnum() or c == '-').lower() or 'lxc' + password = pve.create_lxc( + new_vmid=instance.vmid, name=clean_name, + template_volid=template.source_template, + cores=template.cores, memory_mb=template.memory_mb, + disk_gb=template.disk_gb, node=node, + ) + instance.root_password = password + pve.guest_action("lxc", instance.vmid, "start", node) + instance.status = models.InstanceStatus.running + except Exception as exc: + instance.status = models.InstanceStatus.error + instance.root_password = f"ERROR: {exc}" + db.commit() + finally: + db.close() + + +@router.post("", response_model=schemas.InstanceOut) +def create_instance( + payload: schemas.InstanceCreate, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: models.User = Depends(get_current_user), +): + template = db.query(models.Template).filter( + models.Template.id == payload.template_id, models.Template.is_active == True + ).first() + if not template: + raise HTTPException(status_code=404, detail="Шаблон не найден") + + for _ in range(100): + try: + vmid = pve.get_next_vmid() + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Proxmox недоступен: {exc}") + exists = db.query(models.Instance).filter( + models.Instance.vmid == vmid, + models.Instance.status != models.InstanceStatus.deleted, + ).first() + if not exists: + break + else: + raise HTTPException(status_code=409, detail="Не удалось найти свободный VMID") + + clean_name = ''.join(c for c in payload.name.strip() if c.isalnum() or c == '-').lower() or 'vps' + + # Proxmox переиспользует освободившиеся vmid — вычищаем мёртвые записи, + # иначе INSERT упадёт с duplicate key по ix_instances_vmid + stale = db.query(models.Instance).filter( + models.Instance.vmid == vmid, + models.Instance.status.in_([ + models.InstanceStatus.error, + models.InstanceStatus.deleted, + models.InstanceStatus.deleting, + ]), + ).all() + for s in stale: + db.delete(s) + if stale: + db.commit() + instance = models.Instance( + name=clean_name, vmid=vmid, node=settings.pve_node, + guest_type=template.guest_type, status=models.InstanceStatus.creating, + owner_id=user.id, template_id=template.id, + ciuser=payload.ciuser or None, + root_password=payload.cipassword or None, + ) + db.add(instance) + db.commit() + db.refresh(instance) + background_tasks.add_task(_provision, instance.id, template.id, settings.pve_node) + return instance + + +@router.get("/{instance_id}", response_model=schemas.InstanceOut) +def get_instance(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="VPS не найден") + return instance + + +@router.get("/{instance_id}/status") +def get_instance_status(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="VPS не найден") + try: + return pve.get_status(instance.guest_type.value, instance.vmid, instance.node) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Proxmox недоступен: {exc}") + + +@router.get("/{instance_id}/ip") +def get_instance_ip(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="VPS не найден") + try: + ip = pve.get_instance_ip(instance.vmid, instance.node) + return {"ip": ip} + except Exception as exc: + return {"ip": f"ошибка: {exc}"} + + +@router.post("/{instance_id}/action") +def instance_action(instance_id: int, payload: schemas.InstanceAction, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="VPS не найден") + try: + pve.guest_action(instance.guest_type.value, instance.vmid, payload.action, instance.node) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Ошибка: {exc}") + if payload.action == "start": + instance.status = models.InstanceStatus.running + elif payload.action in ("stop", "shutdown"): + instance.status = models.InstanceStatus.stopped + db.commit() + return {"ok": True} + + +@router.get("/{instance_id}/ip") +def get_instance_ip(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + """Возвращает IP-адрес инстанса.""" + instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="VPS не найден") + if user.role != models.Role.admin and instance.owner_id != user.id: + raise HTTPException(status_code=403, detail="Нет доступа") + try: + ip = pve.get_instance_ip(instance.vmid, instance.node) + return {"ip": ip} + except Exception as exc: + return {"ip": f"ошибка: {exc}"} + +@router.get("/{instance_id}/ip") +def get_instance_ip(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + """Возвращает IP-адрес инстанса.""" + instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="VPS не найден") + if user.role != models.Role.admin and instance.owner_id != user.id: + raise HTTPException(status_code=403, detail="Нет доступа") + try: + ip = pve.get_instance_ip(instance.vmid, instance.node) + return {"ip": ip} + except Exception as exc: + return {"ip": f"ошибка: {exc}"} + +@router.get("/{instance_id}/live") +def get_instance_live(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + """Live CPU/RAM/Memory показатели.""" + instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="VPS не найден") + try: + return pve.get_live_stats(instance.vmid, instance.node) + except Exception as exc: + return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "error", "error": str(exc)} + +@router.delete("/{instance_id}") +def delete_instance(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="VPS не найден") + try: + pve.delete_guest(instance.guest_type.value, instance.vmid, instance.node) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Ошибка удаления: {exc}") + instance.status = models.InstanceStatus.deleted + db.commit() + return {"ok": True} + + +@router.get("/{instance_id}/console") +def instance_console(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() + if not instance: + raise HTTPException(status_code=404, detail="VPS не найден") + try: + ticket = pve.get_vnc_ticket(instance.guest_type.value, instance.vmid, instance.node) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Консоль недоступна: {exc}") + ticket["instance_id"] = instance.id + return ticket diff --git a/backend/app/routers/templates.py b/backend/app/routers/templates.py new file mode 100644 index 0000000..15f0ec1 --- /dev/null +++ b/backend/app/routers/templates.py @@ -0,0 +1,47 @@ +from typing import List + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from .. import models, schemas, proxmox_client as pve +from ..database import get_db +from ..deps import get_current_user, require_admin + +router = APIRouter(prefix="/templates", tags=["templates"]) + + +@router.get("", response_model=List[schemas.TemplateOut]) +def list_templates(db: Session = Depends(get_db), _=Depends(get_current_user)): + return db.query(models.Template).filter(models.Template.is_active == True).all() # noqa: E712 + + +@router.post("", response_model=schemas.TemplateOut) +def create_template( + payload: schemas.TemplateCreate, + db: Session = Depends(get_db), + _=Depends(require_admin), +): + tpl = models.Template(**payload.model_dump()) + db.add(tpl) + db.commit() + db.refresh(tpl) + return tpl + + +@router.get("/from-proxmox") +def list_proxmox_templates(_=Depends(require_admin)): + try: vm_tpls = pve.list_vm_templates() + except: vm_tpls = [] + try: lxc_tpls = pve.list_lxc_templates() + except: lxc_tpls = [] + return {"vm_templates": vm_tpls, "lxc_templates": lxc_tpls} + +@router.delete("/{template_id}") +def deactivate_template( + template_id: int, db: Session = Depends(get_db), _=Depends(require_admin) +): + tpl = db.query(models.Template).filter(models.Template.id == template_id).first() + if tpl: + tpl.is_active = False + db.commit() + return {"ok": True} diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..c991157 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,91 @@ +from datetime import datetime +from typing import Optional, Optional + +from pydantic import BaseModel, EmailStr + +from .models import Role, GuestType, InstanceStatus + + +# ---------- Auth ---------- +class UserCreate(BaseModel): + email: EmailStr + password: str + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserOut(BaseModel): + id: int + email: EmailStr + role: Role + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +# ---------- Templates ---------- +class TemplateCreate(BaseModel): + name: str + description: str = "" + guest_type: GuestType + source_vmid: Optional[int] = None + source_template: Optional[str] = None + cores: int = 1 + memory_mb: int = 1024 + disk_gb: int = 10 + + +class TemplateOut(BaseModel): + id: int + name: str + description: str + guest_type: GuestType + cores: int + memory_mb: int + disk_gb: int + is_active: bool + + class Config: + from_attributes = True + + +# ---------- Instances ---------- +class InstanceCreate(BaseModel): + ciuser: str = "" + cipassword: str = "" + name: str + template_id: int + + +class InstanceOut(BaseModel): + id: int + name: str + vmid: int + node: str + guest_type: GuestType + status: InstanceStatus + template_id: int + template: Optional["TemplateOut"] = None + owner_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class InstanceCreatedOut(InstanceOut): + root_password: Optional[str] = None + + +class InstanceAction(BaseModel): + action: str # start | stop | reboot | shutdown diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..693bfe7 --- /dev/null +++ b/backend/app/security.py @@ -0,0 +1,35 @@ +from datetime import datetime, timedelta +from typing import Optional + +from jose import jwt, JWTError +from passlib.context import CryptContext + +from .config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(data: dict, expires_minutes: Optional[int] = None) -> str: + to_encode = data.copy() + expire = datetime.utcnow() + timedelta( + minutes=expires_minutes or settings.access_token_expire_minutes + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM) + + +def decode_access_token(token: str) -> Optional[dict]: + try: + return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) + except JWTError: + return None diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1aa41a9 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +sqlalchemy==2.0.30 +psycopg2-binary==2.9.9 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +pydantic==2.7.4 +pydantic-settings==2.3.4 +proxmoxer==2.0.1 +requests==2.32.3 +python-multipart==0.0.9 +websockets==12.0 +httpx==0.27.0 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3a08de2 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-slim AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 5173 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fc0c31a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + Panel — VPS на вашем Proxmox + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..1acd6fd --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 5173; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri /index.html; + } + + location /api/ { + proxy_pass http://backend:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..43c0cba --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "proxmox-vps-panel-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --host --port 5173" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.24.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.3.3" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..7945aaa --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState, createContext, useContext } from "react"; +import { Routes, Route, Navigate, Link, useNavigate } from "react-router-dom"; +import { api } from "./api.js"; +import Login from "./pages/Login.jsx"; +import Register from "./pages/Register.jsx"; +import Dashboard from "./pages/Dashboard.jsx"; +import InstanceDetail from "./pages/InstanceDetail.jsx"; +import AdminTemplates from "./pages/AdminTemplates.jsx"; +import AdminUsers from "./pages/AdminUsers.jsx"; + +export const AuthContext = createContext(null); +export const useAuth = () => useContext(AuthContext); + +function useBootstrap() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!localStorage.getItem("token")) { + setLoading(false); + return; + } + api + .me() + .then(setUser) + .catch(() => { + localStorage.removeItem("token"); + }) + .finally(() => setLoading(false)); + }, []); + + return { user, setUser, loading }; +} + +function Protected({ user, loading, children }) { + if (loading) return null; + if (!user) return ; + return children; +} + +function Topbar() { + const { user, setUser } = useAuth(); + const navigate = useNavigate(); + + return ( +
+
+ $ vps-panel +
+ {user && ( +
+ Мои VPS + {user.role === "admin" && Шаблоны} + {user.role === "admin" && Пользователи} + {user.email} + +
+ )} +
+ ); +} + +export default function App() { + const { user, setUser, loading } = useBootstrap(); + + return ( + +
+ + + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + +
+
+ ); +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..5add898 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,75 @@ +const BASE = "/api"; + +function getToken() { + return localStorage.getItem("token"); +} + +async function request(path, { method = "GET", body, auth = true } = {}) { + const headers = {}; + let payload = body; + + if (body instanceof URLSearchParams) { + headers["Content-Type"] = "application/x-www-form-urlencoded"; + } else if (body !== undefined) { + headers["Content-Type"] = "application/json"; + payload = JSON.stringify(body); + } + + if (auth) { + const token = getToken(); + if (token) headers["Authorization"] = `Bearer ${token}`; + } + + const res = await fetch(`${BASE}${path}`, { method, headers, body: payload }); + + if (!res.ok) { + let detail = res.statusText; + try { + const data = await res.json(); + detail = data.detail || detail; + } catch { + /* тело не JSON */ + } + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + + if (res.status === 204) return null; + return res.json(); +} + +export const api = { + register: (email, password) => + request("/auth/register", { method: "POST", body: { email, password }, auth: false }), + + login: async (email, password) => { + const form = new URLSearchParams(); + form.set("username", email); + form.set("password", password); + const data = await request("/auth/login", { method: "POST", body: form, auth: false }); + localStorage.setItem("token", data.access_token); + return data; + }, + + logout: () => localStorage.removeItem("token"), + + me: () => request("/auth/me"), + + listTemplates: () => request("/templates"), + createTemplate: (payload) => request("/templates", { method: "POST", body: payload }), + + listInstances: () => request("/instances"), + createInstance: (payload) => request("/instances", { method: "POST", body: payload }), + getInstance: (id) => request(`/instances/${id}`), + getInstanceStatus: (id) => request(`/instances/${id}/status`), + instanceAction: (id, action) => + request(`/instances/${id}/action`, { method: "POST", body: { action } }), + getInstanceLive: (id) => request(`/instances/${id}/live`), + getInstanceIp: (id) => request(`/instances/${id}/ip`), + deleteInstance: (id) => request(`/instances/${id}`, { method: "DELETE" }), + getConsole: (id) => request(`/instances/${id}/console`), + + fetchProxmoxTemplates: () => request("/templates/from-proxmox"), + deleteTemplate: (id) => request(`/templates/${id}`, { method: "DELETE" }), + listUsers: () => request("/admin/users"), + toggleUser: (id) => request(`/admin/users/${id}/toggle-active`, { method: "POST" }), +}; diff --git a/frontend/src/components/ConsoleViewer.jsx b/frontend/src/components/ConsoleViewer.jsx new file mode 100644 index 0000000..8c9505b --- /dev/null +++ b/frontend/src/components/ConsoleViewer.jsx @@ -0,0 +1,57 @@ +import React, { useEffect, useRef, useState } from "react"; +import { api } from "../api.js"; + +const NOVNC_CDN = "https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/lib/rfb.js"; + +export default function ConsoleViewer({ instanceId }) { + const containerRef = useRef(null); + const rfbRef = useRef(null); + const [status, setStatus] = useState("Подключение…"); + const [error, setError] = useState(""); + + useEffect(() => { + let cancelled = false; + + async function connect() { + try { + const info = await api.getConsole(instanceId); + const { default: RFB } = await import(/* @vite-ignore */ NOVNC_CDN); + if (cancelled) return; + + const proto = window.location.protocol === "https:" ? "wss" : "ws"; + const wsUrl = + `${proto}://${window.location.host}/api/console/ws?` + + `node=${encodeURIComponent(info.node)}&vmid=${info.vmid}&guest_type=${info.guest_type}` + + `&port=${info.port}&ticket=${encodeURIComponent(info.ticket)}`; + + const rfb = new RFB(containerRef.current, wsUrl); + rfb.addEventListener("connect", () => !cancelled && setStatus("Подключено")); + rfb.addEventListener("disconnect", () => !cancelled && setStatus("Соединение закрыто")); + rfbRef.current = rfb; + } catch (err) { + if (!cancelled) setError(err.message || String(err)); + } + } + + connect(); + return () => { + cancelled = true; + rfbRef.current?.disconnect?.(); + }; + }, [instanceId]); + + return ( +
+
+ {error ? "Ошибка консоли" : status} +
+ {error && ( +
+ {error}. Консоль VNC зависит от версии Proxmox и настроек аутентификации — см. README, раздел + «Консоль VNC». +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/InstanceCard.jsx b/frontend/src/components/InstanceCard.jsx new file mode 100644 index 0000000..ec65ee0 --- /dev/null +++ b/frontend/src/components/InstanceCard.jsx @@ -0,0 +1,116 @@ +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api.js"; + +const STATUS_LABEL = { + creating: "создаётся", running: "работает", stopped: "остановлен", + error: "ошибка", deleting: "удаляется", deleted: "удалён", +}; + +function formatUptime(sec) { + if (!sec || sec <= 0) return "—"; + const d = Math.floor(sec / 86400); + const h = Math.floor((sec % 86400) / 3600); + const m = Math.floor((sec % 3600) / 60); + if (d > 0) return `${d}д ${h}ч`; + if (h > 0) return `${h}ч ${m}м`; + return `${m}м`; +} + +function formatMem(mb) { + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} ГБ`; + return `${fmt1(mb)} МБ`; +} + +function fmt1(v) { + const n = Number(v); + return Number.isFinite(n) ? n.toFixed(1) : v; +} + +export default function InstanceCard({ instance, onAction, onDelete }) { + const [ip, setIp] = useState("..."); + const [live, setLive] = useState(null); + + useEffect(() => { + if (instance.status === "running") { + api.getInstanceIp(instance.id).then((r) => setIp(r.ip)).catch(() => setIp("—")); + api.getInstanceLive(instance.id).then((r) => setLive(r)).catch(() => setLive(null)); + const interval = setInterval(() => { + api.getInstanceLive(instance.id).then((r) => setLive(r)).catch(() => {}); + }, 5000); + return () => clearInterval(interval); + } else { + setIp("—"); + setLive(null); + } + }, [instance.id, instance.status]); + + const cpuBar = live && live.cpu > 0 ? ( +
+ 🔥 {live.cpu}% +
+
80 ? "#f44336" : live.cpu > 50 ? "#ff9800" : "#4caf50", + borderRadius: 4, transition: "width 0.5s" }} /> +
+
+ ) : null; + + const memBar = live && live.mem_total > 0 ? ( +
+ 🧠 {fmt1(formatMem(live.mem_used / (1024 * 1024)))} / {fmt1(formatMem(live.mem_total / (1024 * 1024)))} +
+
0.8 ? "#f44336" : (live.mem_used / live.mem_total) > 0.5 ? "#ff9800" : "#4caf50", + borderRadius: 4, transition: "width 0.5s" }} /> +
+
+ ) : null; + + return ( +
+
+ + {STATUS_LABEL[instance.status] || instance.status} + {instance.guest_type === "vm" ? "VM" : "LXC"} + {live && live.status === "running" && ( + + ⏱ {formatUptime(live.uptime)} + + )} +
+
+ {instance.name} +
+ + {/* Live-показатели */} + {live && live.status === "running" && ( +
+ {cpuBar} + {memBar} +
+ )} + + {/* Статическая информация */} +
+ vmid {instance.vmid} · node {instance.node} + {instance.template && ( + <> · 💻 {instance.template.cores} vCPU · 🧠 {instance.template.memory_mb} МБ · 💾 {instance.template.disk_gb} ГБ + )} +
+
+ 🌐 {ip} + {instance.ciuser && <> · 👤 {instance.ciuser}} + {instance.root_password && ( + · 🔑 {instance.root_password} + )} +
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..8fe5f15 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App.jsx"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + +); diff --git a/frontend/src/pages/AdminTemplates.jsx b/frontend/src/pages/AdminTemplates.jsx new file mode 100644 index 0000000..6da676d --- /dev/null +++ b/frontend/src/pages/AdminTemplates.jsx @@ -0,0 +1,74 @@ +import React, { useEffect, useState } from "react"; +import { api } from "../api.js"; + +const empty = { name: "", description: "", guest_type: "vm", source_vmid: "", source_template: "", cores: 1, memory_mb: 1024, disk_gb: 10 }; + +export default function AdminTemplates() { + const [templates, setTemplates] = useState([]); + const [proxmoxTemplates, setProxmoxTemplates] = useState(null); + const [form, setForm] = useState(empty); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function refresh() { setTemplates(await api.listTemplates()); } + async function fetchProxmox() { setBusy(true); try { setProxmoxTemplates(await api.fetchProxmoxTemplates()); } catch (err) { setError(err.message); } finally { setBusy(false); } } + async function importVm(vm) { await api.createTemplate({ name: vm.name || "VM-"+vm.vmid, guest_type: "vm", source_vmid: vm.vmid, cores: vm.cores, memory_mb: vm.memory_mb, disk_gb: vm.disk_gb || 10 }); await refresh(); } + async function importLxc(lxc) { await api.createTemplate({ name: lxc.name, guest_type: "lxc", source_template: lxc.volid, cores: 1, memory_mb: 1024, disk_gb: 8 }); await refresh(); } + async function deleteTemplate(id) { if (!confirm("Удалить шаблон?")) return; await api.deleteTemplate(id); await refresh(); } + + useEffect(() => { refresh(); }, []); + function set(f, v) { setForm(fr => ({ ...fr, [f]: v })); } + + async function onSubmit(e) { e.preventDefault(); setError(""); setBusy(true); try { await api.createTemplate({ ...form, cores: Number(form.cores), memory_mb: Number(form.memory_mb), disk_gb: Number(form.disk_gb), source_vmid: form.source_vmid ? Number(form.source_vmid) : null, source_template: form.source_template || null }); setForm(empty); await refresh(); } catch (err) { setError(err.message); } finally { setBusy(false); } } + + return ( +
+

Шаблоны VPS

+ {error &&
{error}
} + +
+

🔍 Найти шаблоны на Proxmox

+ + {proxmoxTemplates && ( +
+ {proxmoxTemplates.vm_templates?.length > 0 && ( +

🖥 VM-шаблоны

+ + {proxmoxTemplates.vm_templates.map(vm => ( + + ))} +
VMIDИмяvCPURAM
{vm.vmid}{vm.name}{vm.cores}{vm.memory_mb} МБ
)} + {proxmoxTemplates.lxc_templates?.length > 0 && ( +

📦 LXC-шаблоны

+ + {proxmoxTemplates.lxc_templates.map((lxc,i) => ( + + ))} +
ИмяХранилищеРазмер
{lxc.name}{lxc.storage}{lxc.size_mb} МБ
)} + {(!proxmoxTemplates.vm_templates?.length && !proxmoxTemplates.lxc_templates?.length) &&

Шаблоны не найдены.

} +
)} +
+ +
+

➕ Добавить вручную

+
+
set("name", e.target.value)} required />
+
+ {form.guest_type === "vm" ?
set("source_vmid", e.target.value)} placeholder="999" required />
+ :
set("source_template", e.target.value)} placeholder="local:vztmpl/..." required />
} +
set("description", e.target.value)} />
+
set("cores", e.target.value)} />
+
set("memory_mb", e.target.value)} />
+
set("disk_gb", e.target.value)} />
+
+ +
+ +

📋 Активные шаблоны

+ + {templates.map(t => ( + + ))} +
НазваниеТипvCPURAMДиск
{t.name}{t.guest_type==="vm"?"VM":"LXC"}{t.cores}{t.memory_mb} МБ{t.disk_gb} ГБ
+
); +} diff --git a/frontend/src/pages/AdminUsers.jsx b/frontend/src/pages/AdminUsers.jsx new file mode 100644 index 0000000..a029594 --- /dev/null +++ b/frontend/src/pages/AdminUsers.jsx @@ -0,0 +1,50 @@ +import React, { useEffect, useState } from "react"; +import { api } from "../api.js"; + +export default function AdminUsers() { + const [users, setUsers] = useState([]); + + async function refresh() { + setUsers(await api.listUsers()); + } + + useEffect(() => { + refresh(); + }, []); + + async function toggle(id) { + await api.toggleUser(id); + await refresh(); + } + + return ( +
+

Пользователи

+

Управление доступом клиентов к панели

+ + + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
EmailРольСтатус
{u.email}{u.role === "admin" ? "Администратор" : "Клиент"}{u.is_active ? "Активен" : "Заблокирован"} + +
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..98a9b8a --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { api } from "../api.js"; +import InstanceCard from "../components/InstanceCard.jsx"; + +const WHITE = "#fff", LIGHT = "#bbb", GREEN = "#69f0ae", DIM = "#777", BG = "#1e1e1e", BG2 = "#2a2a2a", BORDER = "#444"; + +const UbuntuLogo = () => (); +const DebianLogo = () => (); +const CentOSLogo = () => (); + +const OS_OPTIONS = [ + { key: "ubuntu", label: "Ubuntu", desc: "Универсальный", logo: UbuntuLogo }, + { key: "debian", label: "Debian", desc: "Стабильный", logo: DebianLogo }, + { key: "centos", label: "CentOS", desc: "Корпоративный", logo: CentOSLogo }, +]; +const PRESETS = [ + { key: "small", label: "S", cpu: 1, ram: 1024, disk: 10 }, + { key: "medium", label: "M", cpu: 2, ram: 2048, disk: 20 }, + { key: "large", label: "L", cpu: 4, ram: 8192, disk: 50 }, + { key: "xlarge", label: "XL", cpu: 8, ram: 16384, disk: 100 }, +]; + +function filterByOS(tpl, os) { if (!os) return tpl; return tpl.filter(t => t.name.toLowerCase().includes(os)); } +function matchPreset(tpl, p) { + const exact = tpl.find(t => t.cores===p.cpu && t.memory_mb===p.ram && t.disk_gb===p.disk); + if (exact) return exact; + return tpl.filter(t => t.cores>=p.cpu && t.memory_mb>=p.ram && t.disk_gb>=p.disk) + .sort((a,b)=>(a.cores+a.memory_mb/1024+a.disk_gb)-(b.cores+b.memory_mb/1024+b.disk_gb))[0]||null; +} + +export default function Dashboard() { + const [instances, setInstances] = useState([]); + const [templates, setTemplates] = useState([]); + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(""); + const [ciuser, setCiuser] = useState("ubuntu"); + const [cipassword, setCipassword] = useState(""); + const [templateId, setTemplateId] = useState(""); + const [selectedOS, setSelectedOS] = useState("ubuntu"); + const [selectedPreset, setSelectedPreset] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + const [inst, tpl] = await Promise.all([api.listInstances(), api.listTemplates()]); + setInstances(inst); setTemplates(tpl); + }, []); + useEffect(() => { refresh().finally(() => setLoading(false)); const i = setInterval(refresh, 8000); return () => clearInterval(i); }, [refresh]); + const osT = filterByOS(templates, selectedOS); + useEffect(() => { + if (selectedPreset) { const p = PRESETS.find(x => x.key===selectedPreset); const m = matchPreset(osT, p); if (m) setTemplateId(String(m.id)); } + else if (osT.length > 0) setTemplateId(String(osT[0].id)); + }, [selectedOS, selectedPreset, templates]); + + async function onCreate(e) { + e.preventDefault(); setError(""); setBusy(true); + try { await api.createInstance({ name, template_id: Number(templateId), ciuser: ciuser||"", cipassword: cipassword||"" }); + setName(""); setCipassword(""); setShowForm(false); setSelectedPreset(null); await refresh(); } + catch (err) { setError(err.message); } finally { setBusy(false); } + } + async function onAction(id, action) { try { await api.instanceAction(id, action); await refresh(); } catch (err) { setError(err.message); } } + async function onDelete(id) { if (!confirm("Удалить VPS?")) return; try { await api.deleteInstance(id); await refresh(); } catch (err) { setError(err.message); } } + + const running = instances.filter(i => i.status==="running").length; + const total = instances.length; + + return ( +
+
+

Мои VPS

+

{total===0?"Нет активных серверов":`${running} из ${total} запущено`}

+ +
+ + {showForm && ( +
+ {error &&
{error}
} + +
+ {OS_OPTIONS.map(os => { const active=selectedOS===os.key; const has=filterByOS(templates,os.key).length>0; return ( + );})} +
+
+
+ setName(e.target.value)} placeholder="my-server" required style={{ width:"100%", padding:"9px 12px", borderRadius:8, border:`1px solid ${BORDER}`, background:BG2, color:WHITE, fontSize:14, boxSizing:"border-box" }}/>
+
+ setCiuser(e.target.value)} placeholder="ubuntu" style={{ width:"100%", padding:"9px 12px", borderRadius:8, border:`1px solid ${BORDER}`, background:BG2, color:WHITE, fontSize:14, boxSizing:"border-box" }}/>
+
+ setCipassword(e.target.value)} placeholder="••••••" style={{ width:"100%", padding:"9px 12px", borderRadius:8, border:`1px solid ${BORDER}`, background:BG2, color:WHITE, fontSize:14, boxSizing:"border-box" }}/>
+
+ +
+ + {PRESETS.map(p=>{const match=matchPreset(osT,p); const avail=!!match; const active=selectedPreset===p.key; return( + );})} +
+ +
)} + + {loading ?
Загрузка...
+ : instances.length===0 ?
☁️
Нет активных VPS
Нажмите «+ Новый VPS» чтобы создать
+ :
{instances.map(inst=>())}
} +
); +} diff --git a/frontend/src/pages/InstanceDetail.jsx b/frontend/src/pages/InstanceDetail.jsx new file mode 100644 index 0000000..1fd57a1 --- /dev/null +++ b/frontend/src/pages/InstanceDetail.jsx @@ -0,0 +1,94 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { api } from "../api.js"; +import ConsoleViewer from "../components/ConsoleViewer.jsx"; + +export default function InstanceDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + const [instance, setInstance] = useState(null); + const [showConsole, setShowConsole] = useState(false); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + const data = await api.getInstance(id); + setInstance(data); + }, [id]); + + useEffect(() => { + refresh(); + const interval = setInterval(refresh, 5000); + return () => clearInterval(interval); + }, [refresh]); + + async function doAction(action) { + setBusy(true); + setError(""); + try { + await api.instanceAction(id, action); + await refresh(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + } + + async function doDelete() { + if (!confirm("Удалить этот VPS безвозвратно?")) return; + setBusy(true); + try { + await api.deleteInstance(id); + navigate("/"); + } catch (err) { + setError(err.message); + setBusy(false); + } + } + + if (!instance) return
Загрузка…
; + + return ( +
+

{instance.name}

+

+ vmid {instance.vmid} · node {instance.node} · {instance.guest_type === "vm" ? "VM (QEMU)" : "LXC"} +

+ + {error &&
{error}
} + +
+
+ {instance.status} +
+
+ + + + + + +
+
+ + {showConsole && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..f9d481e --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,58 @@ +import React, { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { api } from "../api.js"; +import { useAuth } from "../App.jsx"; + +export default function Login() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const { setUser } = useAuth(); + const navigate = useNavigate(); + + async function onSubmit(e) { + e.preventDefault(); + setError(""); + setBusy(true); + try { + await api.login(email, password); + const me = await api.me(); + setUser(me); + navigate("/"); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + } + + return ( +
+

Вход

+

Личный кабинет управления VPS

+ {error &&
{error}
} +
+
+ + setEmail(e.target.value)} type="email" required /> +
+
+ + setPassword(e.target.value)} + type="password" + required + /> +
+ +
+

+ Нет аккаунта? Зарегистрироваться +

+
+ ); +} diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx new file mode 100644 index 0000000..7229e9e --- /dev/null +++ b/frontend/src/pages/Register.jsx @@ -0,0 +1,61 @@ +import React, { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { api } from "../api.js"; + +export default function Register() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [done, setDone] = useState(false); + const [busy, setBusy] = useState(false); + const navigate = useNavigate(); + + async function onSubmit(e) { + e.preventDefault(); + setError(""); + setBusy(true); + try { + await api.register(email, password); + setDone(true); + setTimeout(() => navigate("/login"), 1200); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + } + + return ( +
+

Регистрация

+

Первый зарегистрированный пользователь получает права администратора

+ {error &&
{error}
} + {done ? ( +
Готово, переходим на страницу входа…
+ ) : ( +
+
+ + setEmail(e.target.value)} type="email" required /> +
+
+ + setPassword(e.target.value)} + type="password" + minLength={6} + required + /> +
+ +
+ )} +

+ Уже есть аккаунт? Войти +

+
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..2aed8bf --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,346 @@ +:root { + --bg: #0b1120; + --surface: #131b2e; + --surface-2: #1a2438; + --border: #26324a; + --text: #e6edf5; + --text-muted: #8b96a8; + --accent: #22d3aa; + --accent-dim: #16826a; + --warn: #f5a623; + --danger: #ef4444; + --font-mono: "IBM Plex Mono", ui-monospace, monospace; + --font-body: "Inter", system-ui, sans-serif; + --radius: 8px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font-body); + -webkit-font-smoothing: antialiased; +} + +button, input, select { + font-family: inherit; +} + +a { color: var(--accent); text-decoration: none; } + +.shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.brand { + font-family: var(--font-mono); + font-weight: 600; + letter-spacing: 0.02em; + font-size: 15px; + display: flex; + align-items: center; + gap: 8px; +} + +.brand .prompt { color: var(--accent); } + +.topbar-right { + display: flex; + align-items: center; + gap: 16px; + font-size: 14px; + color: var(--text-muted); +} + +.container { + max-width: 1040px; + margin: 0 auto; + padding: 32px 24px 80px; + width: 100%; +} + +.page-title { + font-family: var(--font-mono); + font-size: 22px; + margin: 0 0 6px; +} + +.page-sub { + color: var(--text-muted); + margin: 0 0 28px; + font-size: 14px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 16px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + cursor: pointer; + font-size: 14px; + transition: border-color 0.15s ease, background 0.15s ease; +} +.btn:hover { border-color: var(--accent-dim); } +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #06231c; + font-weight: 600; +} +.btn-primary:hover { background: #2ee6bb; } + +.btn-danger { + border-color: #5c2626; + color: #ff8a8a; +} +.btn-danger:hover { border-color: var(--danger); } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} +.field label { + font-size: 13px; + color: var(--text-muted); +} +.field input, .field select { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + color: var(--text); + font-size: 14px; +} +.field input:focus, .field select:focus { + outline: none; + border-color: var(--accent-dim); +} + +.error-box { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.4); + color: #ff9d9d; + padding: 10px 14px; + border-radius: var(--radius); + font-size: 13px; + margin-bottom: 16px; +} + +.auth-wrap { + max-width: 380px; + margin: 90px auto; +} + +.instance-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.instance-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 18px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.status-row { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); + flex-shrink: 0; +} +.dot.running { background: var(--accent); box-shadow: 0 0 0 3px rgba(34, 211, 170, 0.2); } +.dot.stopped { background: var(--text-muted); } +.dot.creating { background: var(--warn); animation: pulse 1.4s infinite; } +.dot.error { background: var(--danger); } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.instance-name { + font-size: 16px; + font-weight: 600; +} + +.instance-meta { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); +} + +.instance-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; +} + +.btn-sm { + padding: 6px 10px; + font-size: 12px; +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} + +.nav-tabs { + display: flex; + gap: 4px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border); +} +.nav-tab { + padding: 10px 16px; + font-size: 14px; + color: var(--text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; +} +.nav-tab.active { + color: var(--text); + border-color: var(--accent); +} + +table.data-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +table.data-table th, table.data-table td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} +table.data-table th { + color: var(--text-muted); + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.console-frame { + background: #000; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border); +} + +.badge { + font-family: var(--font-mono); + font-size: 11px; + padding: 2px 8px; + border-radius: 100px; + border: 1px solid var(--border); + color: var(--text-muted); +} + +/* === Компактный размер карточек VPS === */ +.instance-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 340px)); + gap: 16px; + justify-content: start; +} +.instance-card { + max-width: 340px; + padding: 14px 16px; +} +.instance-name { + font-size: 16px; +} +.instance-meta { + font-size: 12px; +} +.instance-actions .btn { + padding: 5px 10px; + font-size: 12px; +} + +/* === Карточки VPS на всю ширину === */ +.instance-grid { + grid-template-columns: 1fr; + gap: 16px; +} +.instance-card { + max-width: none; + width: 100%; +} + +/* === Сетка карточек: несколько в ряд === */ +.instance-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 20px; +} +.instance-card { + max-width: none; + width: auto; +} + +/* === Более светлый фон страницы === */ +body { + background: #2b3448 !important; +} +.container, +.page-title, +.page-sub { + color: #f0f2f7; +} +.instance-card, +.card { + background: #39435c !important; + border: 1px solid #4d5878 !important; +} +.instance-meta { + color: #c3cadb; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..4f89489 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + host: true, + port: 5173, + proxy: { + "/api": { + target: "http://backend:8000", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + ws: true, + }, + }, + }, +}); diff --git a/proxmox-vps-panel-1.zip b/proxmox-vps-panel-1.zip deleted file mode 100644 index d6f33ad..0000000 Binary files a/proxmox-vps-panel-1.zip and /dev/null differ