From ac74a700cef8318261c2b186f901a3907ca7f432 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Jul 2026 15:10:47 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D0=BF=D0=B0=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=20=D0=B0=D1=80=D1=85=D0=B8=D0=B2=20Proxmox-V?= =?UTF-8?q?PS-Panel.rar=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=81=D0=BE=D0=B4=D0=B5=D1=80=D0=B6=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=B2=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env | 5 + backend/.env.example | 17 ++ backend/Dockerfile | 12 + backend/app/__init__.py | 0 backend/app/config.py | 25 ++ backend/app/database.py | 17 ++ backend/app/deps.py | 35 +++ backend/app/main.py | 29 ++ backend/app/models.py | 81 +++++ backend/app/proxmox_client.py | 319 ++++++++++++++++++++ backend/app/routers/__init__.py | 0 backend/app/routers/admin.py | 25 ++ backend/app/routers/auth.py | 43 +++ backend/app/routers/console.py | 69 +++++ backend/app/routers/instances.py | 231 +++++++++++++++ backend/app/routers/templates.py | 47 +++ backend/app/schemas.py | 91 ++++++ backend/app/security.py | 35 +++ backend/requirements.txt | 14 + frontend/Dockerfile | 11 + frontend/index.html | 14 + frontend/nginx.conf | 20 ++ frontend/package.json | 20 ++ frontend/src/App.jsx | 118 ++++++++ frontend/src/api.js | 75 +++++ frontend/src/components/ConsoleViewer.jsx | 57 ++++ frontend/src/components/InstanceCard.jsx | 116 ++++++++ frontend/src/main.jsx | 13 + frontend/src/pages/AdminTemplates.jsx | 74 +++++ frontend/src/pages/AdminUsers.jsx | 50 ++++ frontend/src/pages/Dashboard.jsx | 113 +++++++ frontend/src/pages/InstanceDetail.jsx | 94 ++++++ frontend/src/pages/Login.jsx | 58 ++++ frontend/src/pages/Register.jsx | 61 ++++ frontend/src/styles.css | 346 ++++++++++++++++++++++ frontend/vite.config.js | 18 ++ proxmox-vps-panel-1.zip | Bin 36975 -> 0 bytes 37 files changed, 2353 insertions(+) create mode 100644 backend/.env create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/deps.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/proxmox_client.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/console.py create mode 100644 backend/app/routers/instances.py create mode 100644 backend/app/routers/templates.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/security.py create mode 100644 backend/requirements.txt create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components/ConsoleViewer.jsx create mode 100644 frontend/src/components/InstanceCard.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/AdminTemplates.jsx create mode 100644 frontend/src/pages/AdminUsers.jsx create mode 100644 frontend/src/pages/Dashboard.jsx create mode 100644 frontend/src/pages/InstanceDetail.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Register.jsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/vite.config.js delete mode 100644 proxmox-vps-panel-1.zip 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 d6f33ad7074bad29f873c29d607769d34515fa7e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36975 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